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.
69    ///
70    /// `All` satisfies every non-admin permission (DDL + all table-level
71    /// operations) but does **not** satisfy `Admin` — user/role management
72    /// is gated behind `is_admin = true` on the principal, not grantable via
73    /// `Permission::All` (spec §9 decision 2). `Select { table: "*" }` is
74    /// not wildcarded (it matches a table literally named `*`).
75    pub fn satisfies(&self, required: &Permission) -> bool {
76        match (self, required) {
77            // All grants every non-admin permission.
78            (Permission::All, Permission::Admin) => false,
79            (Permission::All, _) => true,
80            (Permission::Admin, Permission::Admin) => true,
81            (Permission::Ddl, Permission::Ddl) => true,
82            (Permission::Select { table: a }, Permission::Select { table: b }) => a == b,
83            (Permission::Insert { table: a }, Permission::Insert { table: b }) => a == b,
84            (Permission::Update { table: a }, Permission::Update { table: b }) => a == b,
85            (Permission::Delete { table: a }, Permission::Delete { table: b }) => a == b,
86            _ => false,
87        }
88    }
89}
90
91impl std::fmt::Display for Permission {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Permission::All => write!(f, "ALL"),
95            Permission::Admin => write!(f, "ADMIN"),
96            Permission::Ddl => write!(f, "DDL"),
97            Permission::Select { table } => write!(f, "SELECT ON {table}"),
98            Permission::Insert { table } => write!(f, "INSERT ON {table}"),
99            Permission::Update { table } => write!(f, "UPDATE ON {table}"),
100            Permission::Delete { table } => write!(f, "DELETE ON {table}"),
101        }
102    }
103}
104
105/// The authenticated identity for a single HTTP request. Injected by the
106/// auth middleware into request extensions.
107#[derive(Debug, Clone)]
108pub struct Principal {
109    pub username: String,
110    pub is_admin: bool,
111    pub roles: Vec<String>,
112    /// All permissions from all roles the user belongs to, pre-resolved.
113    pub permissions: Vec<Permission>,
114}
115
116impl Principal {
117    /// Check whether this principal has the required permission.
118    pub fn has_permission(&self, required: &Permission) -> bool {
119        if self.is_admin {
120            return true;
121        }
122        self.permissions.iter().any(|p| p.satisfies(required))
123    }
124}
125
126// ── Password hashing (Argon2id) ──────────────────────────────────────────
127
128/// Hash a password using Argon2id with a fresh random salt.
129///
130/// Returns a PHC string that encodes the algorithm, version, parameters, salt,
131/// and hash — suitable for storage as `UserEntry::password_hash` and verifiable
132/// via [`verify_password`].
133pub fn hash_password(password: &str) -> Result<String, String> {
134    use argon2::{
135        password_hash::{PasswordHasher, SaltString},
136        Algorithm, Argon2, Version,
137    };
138    use getrandom::getrandom;
139    // Reuse the same OWASP-minimum parameters as the encryption KEK derivation.
140    let params = argon2::Params::new(19 * 1024, 2, 1, None).map_err(|e| e.to_string())?;
141    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
142    // Generate salt via getrandom (always available in core, no feature gate).
143    let mut salt_bytes = [0u8; 32];
144    getrandom(&mut salt_bytes).map_err(|e| e.to_string())?;
145    let salt = SaltString::encode_b64(&salt_bytes).map_err(|e| e.to_string())?;
146    let hash = argon2
147        .hash_password(password.as_bytes(), &salt)
148        .map_err(|e| e.to_string())?;
149    Ok(hash.to_string())
150}
151
152/// Verify a password against a stored PHC hash. Returns `Ok(true)` on match,
153/// `Ok(false)` on mismatch, `Err` on malformed hash.
154pub fn verify_password(password: &str, phc_hash: &str) -> Result<bool, String> {
155    use argon2::{password_hash::PasswordVerifier, Argon2};
156    let parsed_hash =
157        argon2::PasswordHash::new(phc_hash).map_err(|e| format!("malformed hash: {e}"))?;
158    Ok(Argon2::default()
159        .verify_password(password.as_bytes(), &parsed_hash)
160        .is_ok())
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn password_hash_round_trip() {
169        let password = "correct horse battery staple";
170        let hash = hash_password(password).unwrap();
171        assert!(verify_password(password, &hash).unwrap());
172        assert!(!verify_password("wrong password", &hash).unwrap());
173    }
174
175    #[test]
176    fn permission_satisfies() {
177        // All satisfies DDL and table-level permissions...
178        assert!(Permission::All.satisfies(&Permission::Ddl));
179        assert!(Permission::All.satisfies(&Permission::Select { table: "t".into() }));
180        assert!(Permission::All.satisfies(&Permission::Insert { table: "t".into() }));
181        // ...but NOT Admin (spec §9 decision 2 — only is_admin grants admin).
182        assert!(!Permission::All.satisfies(&Permission::Admin));
183        // Exact table match.
184        assert!(Permission::Select { table: "t".into() }
185            .satisfies(&Permission::Select { table: "t".into() }));
186        assert!(
187            !Permission::Select { table: "t".into() }.satisfies(&Permission::Select {
188                table: "other".into()
189            })
190        );
191        // Cross-kind never satisfies.
192        assert!(!Permission::Select { table: "t".into() }
193            .satisfies(&Permission::Insert { table: "t".into() }));
194        // Ddl does not satisfy Admin either.
195        assert!(!Permission::Ddl.satisfies(&Permission::Admin));
196    }
197
198    #[test]
199    fn principal_admin_bypasses_checks() {
200        let principal = Principal {
201            username: "admin".into(),
202            is_admin: true,
203            roles: vec![],
204            permissions: vec![],
205        };
206        assert!(principal.has_permission(&Permission::Admin));
207        assert!(principal.has_permission(&Permission::Select {
208            table: "anything".into()
209        }));
210    }
211}