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};
13
14/// A stored user with Argon2id-hashed credentials.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct UserEntry {
17    /// Stable, monotonic user id.
18    pub id: u64,
19    /// Unique username (case-sensitive).
20    pub username: String,
21    /// Argon2id PHC string (includes salt + params; verifiable via
22    /// `PasswordVerifier::verify`).
23    #[serde(skip_serializing_if = "String::is_empty", default)]
24    pub password_hash: String,
25    /// Role names granted to this user.
26    #[serde(default)]
27    pub roles: Vec<String>,
28    /// Bypasses all permission checks (full admin).
29    #[serde(default)]
30    pub is_admin: bool,
31    /// Epoch at which the user was created.
32    pub created_epoch: u64,
33}
34
35/// A named collection of permissions.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RoleEntry {
38    /// Unique role name (case-sensitive).
39    pub name: String,
40    /// Permissions granted to this role.
41    #[serde(default)]
42    pub permissions: Vec<Permission>,
43    /// Epoch at which the role was created.
44    pub created_epoch: u64,
45}
46
47/// A permission granted to a role. Mirrors SQL `GRANT` / `REVOKE`.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum Permission {
51    /// All permissions on all tables (`GRANT ALL`).
52    All,
53    /// `SELECT` on a specific table.
54    Select { table: String },
55    /// `INSERT` on a specific table.
56    Insert { table: String },
57    /// `UPDATE` on a specific table.
58    Update { table: String },
59    /// `DELETE` on a specific table.
60    Delete { table: String },
61    /// `SELECT` limited to named columns.
62    SelectColumns { table: String, columns: Vec<String> },
63    /// `INSERT` limited to named columns.
64    InsertColumns { table: String, columns: Vec<String> },
65    /// `UPDATE` limited to named columns.
66    UpdateColumns { table: String, columns: Vec<String> },
67    /// DDL: `CREATE` / `DROP` / `ALTER TABLE`.
68    Ddl,
69    /// Admin: `CREATE USER` / `GRANT` / `REVOKE` / `CREATE ROLE`.
70    Admin,
71}
72
73impl Permission {
74    /// Check whether this permission satisfies a required permission.
75    ///
76    /// `All` satisfies every non-admin permission (DDL + all table-level
77    /// operations) but does **not** satisfy `Admin` — user/role management
78    /// is gated behind `is_admin = true` on the principal, not grantable via
79    /// `Permission::All` (spec §9 decision 2). `Select { table: "*" }` is
80    /// not wildcarded (it matches a table literally named `*`).
81    pub fn satisfies(&self, required: &Permission) -> bool {
82        match (self, required) {
83            // All grants every non-admin permission.
84            (Permission::All, Permission::Admin) => false,
85            (Permission::All, _) => true,
86            (Permission::Admin, Permission::Admin) => true,
87            (Permission::Ddl, Permission::Ddl) => true,
88            (Permission::Select { table: a }, Permission::Select { table: b }) => a == b,
89            (Permission::Insert { table: a }, Permission::Insert { table: b }) => a == b,
90            (Permission::Update { table: a }, Permission::Update { table: b }) => a == b,
91            (Permission::Delete { table: a }, Permission::Delete { table: b }) => a == b,
92            (
93                Permission::SelectColumns {
94                    table: a,
95                    columns: granted,
96                },
97                Permission::SelectColumns {
98                    table: b,
99                    columns: required,
100                },
101            )
102            | (
103                Permission::InsertColumns {
104                    table: a,
105                    columns: granted,
106                },
107                Permission::InsertColumns {
108                    table: b,
109                    columns: required,
110                },
111            )
112            | (
113                Permission::UpdateColumns {
114                    table: a,
115                    columns: granted,
116                },
117                Permission::UpdateColumns {
118                    table: b,
119                    columns: required,
120                },
121            ) => a == b && required.iter().all(|column| granted.contains(column)),
122            _ => false,
123        }
124    }
125}
126
127impl std::fmt::Display for Permission {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Permission::All => write!(f, "ALL"),
131            Permission::Admin => write!(f, "ADMIN"),
132            Permission::Ddl => write!(f, "DDL"),
133            Permission::Select { table } => write!(f, "SELECT ON {table}"),
134            Permission::Insert { table } => write!(f, "INSERT ON {table}"),
135            Permission::Update { table } => write!(f, "UPDATE ON {table}"),
136            Permission::Delete { table } => write!(f, "DELETE ON {table}"),
137            Permission::SelectColumns { table, columns } => {
138                write!(f, "SELECT ({}) ON {table}", columns.join(", "))
139            }
140            Permission::InsertColumns { table, columns } => {
141                write!(f, "INSERT ({}) ON {table}", columns.join(", "))
142            }
143            Permission::UpdateColumns { table, columns } => {
144                write!(f, "UPDATE ({}) ON {table}", columns.join(", "))
145            }
146        }
147    }
148}
149
150/// The authenticated identity for a single HTTP request. Injected by the
151/// auth middleware into request extensions.
152#[derive(Debug, Clone)]
153pub struct Principal {
154    pub username: String,
155    pub is_admin: bool,
156    pub roles: Vec<String>,
157    /// All permissions from all roles the user belongs to, pre-resolved.
158    pub permissions: Vec<Permission>,
159}
160
161impl Principal {
162    /// Check whether this principal has the required permission.
163    pub fn has_permission(&self, required: &Permission) -> bool {
164        if self.is_admin {
165            return true;
166        }
167        self.permissions.iter().any(|p| p.satisfies(required))
168    }
169
170    pub fn column_access(&self, table: &str, operation: ColumnOperation) -> ColumnAccess {
171        if self.is_admin
172            || self
173                .permissions
174                .iter()
175                .any(|permission| matches!(permission, Permission::All))
176        {
177            return ColumnAccess::All;
178        }
179        let full = self
180            .permissions
181            .iter()
182            .any(|permission| match (operation, permission) {
183                (ColumnOperation::Select, Permission::Select { table: granted })
184                | (ColumnOperation::Insert, Permission::Insert { table: granted })
185                | (ColumnOperation::Update, Permission::Update { table: granted }) => {
186                    granted == table
187                }
188                _ => false,
189            });
190        if full {
191            return ColumnAccess::All;
192        }
193        let mut columns = Vec::new();
194        for permission in &self.permissions {
195            let grant = match (operation, permission) {
196                (
197                    ColumnOperation::Select,
198                    Permission::SelectColumns {
199                        table: granted,
200                        columns,
201                    },
202                )
203                | (
204                    ColumnOperation::Insert,
205                    Permission::InsertColumns {
206                        table: granted,
207                        columns,
208                    },
209                )
210                | (
211                    ColumnOperation::Update,
212                    Permission::UpdateColumns {
213                        table: granted,
214                        columns,
215                    },
216                ) if granted == table => Some(columns),
217                _ => None,
218            };
219            if let Some(grant) = grant {
220                for column in grant {
221                    if !columns.contains(column) {
222                        columns.push(column.clone());
223                    }
224                }
225            }
226        }
227        if columns.is_empty() {
228            ColumnAccess::Denied
229        } else {
230            ColumnAccess::Columns(columns)
231        }
232    }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum ColumnOperation {
237    Select,
238    Insert,
239    Update,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum ColumnAccess {
244    All,
245    Columns(Vec<String>),
246    Denied,
247}
248
249// ── Password hashing (Argon2id) ──────────────────────────────────────────
250
251/// Hash a password using Argon2id with a fresh random salt.
252///
253/// Returns a PHC string that encodes the algorithm, version, parameters, salt,
254/// and hash — suitable for storage as `UserEntry::password_hash` and verifiable
255/// via [`verify_password`].
256pub fn hash_password(password: &str) -> Result<String, String> {
257    use argon2::{
258        password_hash::{PasswordHasher, SaltString},
259        Algorithm, Argon2, Version,
260    };
261    use getrandom::getrandom;
262    // Reuse the same OWASP-minimum parameters as the encryption KEK derivation.
263    let params = argon2::Params::new(19 * 1024, 2, 1, None).map_err(|e| e.to_string())?;
264    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
265    // Generate salt via getrandom (always available in core, no feature gate).
266    let mut salt_bytes = [0u8; 32];
267    getrandom(&mut salt_bytes).map_err(|e| e.to_string())?;
268    let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| e.to_string())?;
269    let hash = argon2
270        .hash_password(password.as_bytes(), &salt)
271        .map_err(|e| e.to_string())?;
272    Ok(hash.to_string())
273}
274
275/// Verify a password against a stored PHC hash. Returns `Ok(true)` on match,
276/// `Ok(false)` on mismatch, `Err` on malformed hash.
277pub fn verify_password(password: &str, phc_hash: &str) -> Result<bool, String> {
278    use argon2::{password_hash::PasswordVerifier, Argon2};
279    let parsed_hash =
280        argon2::PasswordHash::new(phc_hash).map_err(|e| format!("malformed hash: {e}"))?;
281    Ok(Argon2::default()
282        .verify_password(password.as_bytes(), &parsed_hash)
283        .is_ok())
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn password_hash_round_trip() {
292        let password = "correct horse battery staple";
293        let hash = hash_password(password).unwrap();
294        assert!(verify_password(password, &hash).unwrap());
295        assert!(!verify_password("wrong password", &hash).unwrap());
296    }
297
298    #[test]
299    fn permission_satisfies() {
300        // All satisfies DDL and table-level permissions...
301        assert!(Permission::All.satisfies(&Permission::Ddl));
302        assert!(Permission::All.satisfies(&Permission::Select { table: "t".into() }));
303        assert!(Permission::All.satisfies(&Permission::Insert { table: "t".into() }));
304        // ...but NOT Admin (spec §9 decision 2 — only is_admin grants admin).
305        assert!(!Permission::All.satisfies(&Permission::Admin));
306        // Exact table match.
307        assert!(Permission::Select { table: "t".into() }
308            .satisfies(&Permission::Select { table: "t".into() }));
309        assert!(
310            !Permission::Select { table: "t".into() }.satisfies(&Permission::Select {
311                table: "other".into()
312            })
313        );
314        // Cross-kind never satisfies.
315        assert!(!Permission::Select { table: "t".into() }
316            .satisfies(&Permission::Insert { table: "t".into() }));
317        // Ddl does not satisfy Admin either.
318        assert!(!Permission::Ddl.satisfies(&Permission::Admin));
319    }
320
321    #[test]
322    fn principal_admin_bypasses_checks() {
323        let principal = Principal {
324            username: "admin".into(),
325            is_admin: true,
326            roles: vec![],
327            permissions: vec![],
328        };
329        assert!(principal.has_permission(&Permission::Admin));
330        assert!(principal.has_permission(&Permission::Select {
331            table: "anything".into()
332        }));
333    }
334}