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    /// DDL: `CREATE` / `DROP` / `ALTER TABLE`.
62    Ddl,
63    /// Admin: `CREATE USER` / `GRANT` / `REVOKE` / `CREATE ROLE`.
64    Admin,
65}
66
67impl Permission {
68    /// Check whether this permission satisfies a required permission. `All`
69    /// satisfies everything; `Select { table: "*" }` is not wildcarded (it
70    /// matches a table literally named `*`).
71    pub fn satisfies(&self, required: &Permission) -> bool {
72        match (self, required) {
73            (Permission::All, _) => true,
74            (Permission::Admin, Permission::Admin) => true,
75            (Permission::Ddl, Permission::Ddl) => true,
76            (Permission::Select { table: a }, Permission::Select { table: b }) => a == b,
77            (Permission::Insert { table: a }, Permission::Insert { table: b }) => a == b,
78            (Permission::Update { table: a }, Permission::Update { table: b }) => a == b,
79            (Permission::Delete { table: a }, Permission::Delete { table: b }) => a == b,
80            _ => false,
81        }
82    }
83}
84
85/// The authenticated identity for a single HTTP request. Injected by the
86/// auth middleware into request extensions.
87#[derive(Debug, Clone)]
88pub struct Principal {
89    pub username: String,
90    pub is_admin: bool,
91    pub roles: Vec<String>,
92    /// All permissions from all roles the user belongs to, pre-resolved.
93    pub permissions: Vec<Permission>,
94}
95
96impl Principal {
97    /// Check whether this principal has the required permission.
98    pub fn has_permission(&self, required: &Permission) -> bool {
99        if self.is_admin {
100            return true;
101        }
102        self.permissions.iter().any(|p| p.satisfies(required))
103    }
104}
105
106// ── Password hashing (Argon2id) ──────────────────────────────────────────
107
108/// Hash a password using Argon2id with a fresh random salt.
109///
110/// Returns a PHC string that encodes the algorithm, version, parameters, salt,
111/// and hash — suitable for storage as `UserEntry::password_hash` and verifiable
112/// via [`verify_password`].
113pub fn hash_password(password: &str) -> Result<String, String> {
114    use argon2::{
115        password_hash::{PasswordHasher, SaltString},
116        Algorithm, Argon2, Version,
117    };
118    use getrandom::getrandom;
119    // Reuse the same OWASP-minimum parameters as the encryption KEK derivation.
120    let params = argon2::Params::new(19 * 1024, 2, 1, None).map_err(|e| e.to_string())?;
121    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
122    // Generate salt via getrandom (always available in core, no feature gate).
123    let mut salt_bytes = [0u8; 32];
124    getrandom(&mut salt_bytes).map_err(|e| e.to_string())?;
125    let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| e.to_string())?;
126    let hash = argon2
127        .hash_password(password.as_bytes(), &salt)
128        .map_err(|e| e.to_string())?;
129    Ok(hash.to_string())
130}
131
132/// Verify a password against a stored PHC hash. Returns `Ok(true)` on match,
133/// `Ok(false)` on mismatch, `Err` on malformed hash.
134pub fn verify_password(password: &str, phc_hash: &str) -> Result<bool, String> {
135    use argon2::{password_hash::PasswordVerifier, Argon2};
136    let parsed_hash =
137        argon2::PasswordHash::new(phc_hash).map_err(|e| format!("malformed hash: {e}"))?;
138    Ok(Argon2::default()
139        .verify_password(password.as_bytes(), &parsed_hash)
140        .is_ok())
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn password_hash_round_trip() {
149        let password = "correct horse battery staple";
150        let hash = hash_password(password).unwrap();
151        assert!(verify_password(password, &hash).unwrap());
152        assert!(!verify_password("wrong password", &hash).unwrap());
153    }
154
155    #[test]
156    fn permission_satisfies() {
157        assert!(Permission::All.satisfies(&Permission::Select { table: "t".into() }));
158        assert!(Permission::Select { table: "t".into() }
159            .satisfies(&Permission::Select { table: "t".into() }));
160        assert!(
161            !Permission::Select { table: "t".into() }.satisfies(&Permission::Select {
162                table: "other".into()
163            })
164        );
165        assert!(!Permission::Select { table: "t".into() }
166            .satisfies(&Permission::Insert { table: "t".into() }));
167    }
168
169    #[test]
170    fn principal_admin_bypasses_checks() {
171        let principal = Principal {
172            username: "admin".into(),
173            is_admin: true,
174            roles: vec![],
175            permissions: vec![],
176        };
177        assert!(principal.has_permission(&Permission::Admin));
178        assert!(principal.has_permission(&Permission::Select {
179            table: "anything".into()
180        }));
181    }
182}