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    /// Immutable catalog identity. Username reuse must not revive a principal.
155    pub user_id: u64,
156    /// User generation paired with `user_id`.
157    pub created_epoch: u64,
158    pub username: String,
159    pub is_admin: bool,
160    pub roles: Vec<String>,
161    /// All permissions from all roles the user belongs to, pre-resolved.
162    pub permissions: Vec<Permission>,
163}
164
165impl Principal {
166    /// Check whether this principal has the required permission.
167    pub fn has_permission(&self, required: &Permission) -> bool {
168        if self.is_admin {
169            return true;
170        }
171        self.permissions.iter().any(|p| p.satisfies(required))
172    }
173
174    pub fn column_access(&self, table: &str, operation: ColumnOperation) -> ColumnAccess {
175        if self.is_admin
176            || self
177                .permissions
178                .iter()
179                .any(|permission| matches!(permission, Permission::All))
180        {
181            return ColumnAccess::All;
182        }
183        let full = self
184            .permissions
185            .iter()
186            .any(|permission| match (operation, permission) {
187                (ColumnOperation::Select, Permission::Select { table: granted })
188                | (ColumnOperation::Insert, Permission::Insert { table: granted })
189                | (ColumnOperation::Update, Permission::Update { table: granted }) => {
190                    granted == table
191                }
192                _ => false,
193            });
194        if full {
195            return ColumnAccess::All;
196        }
197        let mut columns = Vec::new();
198        for permission in &self.permissions {
199            let grant = match (operation, permission) {
200                (
201                    ColumnOperation::Select,
202                    Permission::SelectColumns {
203                        table: granted,
204                        columns,
205                    },
206                )
207                | (
208                    ColumnOperation::Insert,
209                    Permission::InsertColumns {
210                        table: granted,
211                        columns,
212                    },
213                )
214                | (
215                    ColumnOperation::Update,
216                    Permission::UpdateColumns {
217                        table: granted,
218                        columns,
219                    },
220                ) if granted == table => Some(columns),
221                _ => None,
222            };
223            if let Some(grant) = grant {
224                for column in grant {
225                    if !columns.contains(column) {
226                        columns.push(column.clone());
227                    }
228                }
229            }
230        }
231        if columns.is_empty() {
232            ColumnAccess::Denied
233        } else {
234            ColumnAccess::Columns(columns)
235        }
236    }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ColumnOperation {
241    Select,
242    Insert,
243    Update,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum ColumnAccess {
248    All,
249    Columns(Vec<String>),
250    Denied,
251}
252
253// ── Password hashing (Argon2id) ──────────────────────────────────────────
254
255/// Hash a password using Argon2id with a fresh random salt.
256///
257/// Returns a PHC string that encodes the algorithm, version, parameters, salt,
258/// and hash — suitable for storage as `UserEntry::password_hash` and verifiable
259/// via [`verify_password`].
260pub fn hash_password(password: &str) -> Result<String, String> {
261    use argon2::{
262        password_hash::{PasswordHasher, SaltString},
263        Algorithm, Argon2, Version,
264    };
265    use getrandom::getrandom;
266    // Reuse the same OWASP-minimum parameters as the encryption KEK derivation.
267    let params = argon2::Params::new(19 * 1024, 2, 1, None).map_err(|e| e.to_string())?;
268    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
269    // Generate salt via getrandom (always available in core, no feature gate).
270    let mut salt_bytes = [0u8; 32];
271    getrandom(&mut salt_bytes).map_err(|e| e.to_string())?;
272    let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| e.to_string())?;
273    let hash = argon2
274        .hash_password(password.as_bytes(), &salt)
275        .map_err(|e| e.to_string())?;
276    Ok(hash.to_string())
277}
278
279/// Verify a password against a stored PHC hash. Returns `Ok(true)` on match,
280/// `Ok(false)` on mismatch, `Err` on malformed hash.
281pub fn verify_password(password: &str, phc_hash: &str) -> Result<bool, String> {
282    use argon2::{password_hash::PasswordVerifier, Argon2};
283    let parsed_hash =
284        argon2::PasswordHash::new(phc_hash).map_err(|e| format!("malformed hash: {e}"))?;
285    Ok(Argon2::default()
286        .verify_password(password.as_bytes(), &parsed_hash)
287        .is_ok())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn password_hash_round_trip() {
296        let password = "correct horse battery staple";
297        let hash = hash_password(password).unwrap();
298        assert!(verify_password(password, &hash).unwrap());
299        assert!(!verify_password("wrong password", &hash).unwrap());
300    }
301
302    #[test]
303    fn permission_satisfies() {
304        // All satisfies DDL and table-level permissions...
305        assert!(Permission::All.satisfies(&Permission::Ddl));
306        assert!(Permission::All.satisfies(&Permission::Select { table: "t".into() }));
307        assert!(Permission::All.satisfies(&Permission::Insert { table: "t".into() }));
308        // ...but NOT Admin (spec §9 decision 2 — only is_admin grants admin).
309        assert!(!Permission::All.satisfies(&Permission::Admin));
310        // Exact table match.
311        assert!(Permission::Select { table: "t".into() }
312            .satisfies(&Permission::Select { table: "t".into() }));
313        assert!(
314            !Permission::Select { table: "t".into() }.satisfies(&Permission::Select {
315                table: "other".into()
316            })
317        );
318        // Cross-kind never satisfies.
319        assert!(!Permission::Select { table: "t".into() }
320            .satisfies(&Permission::Insert { table: "t".into() }));
321        // Ddl does not satisfy Admin either.
322        assert!(!Permission::Ddl.satisfies(&Permission::Admin));
323    }
324
325    #[test]
326    fn principal_admin_bypasses_checks() {
327        let principal = Principal {
328            user_id: 0,
329            created_epoch: 0,
330            username: "admin".into(),
331            is_admin: true,
332            roles: vec![],
333            permissions: vec![],
334        };
335        assert!(principal.has_permission(&Permission::Admin));
336        assert!(principal.has_permission(&Permission::Select {
337            table: "anything".into()
338        }));
339    }
340}