Skip to main content

powdb_auth/
store.rs

1//! Persisted user/role store backed by `auth.json`.
2//!
3//! Passwords are never stored in plaintext — only argon2id PHC hashes.
4//! On Unix the on-disk file is written with mode `0600`. `powdb-server` loads
5//! this store at startup and authenticates every connection against it;
6//! `powdb-cli` manages it via the `useradd`/`passwd`/`userdel` subcommands.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io;
11use std::path::Path;
12
13use serde::{Deserialize, Serialize};
14use zeroize::Zeroizing;
15
16use crate::error::AuthError;
17use crate::hash::{hash_password, verify_password};
18use crate::role::Role;
19
20const AUTH_FILE: &str = "auth.json";
21
22/// A single user record. The password is stored only as an argon2 PHC hash.
23#[derive(Serialize, Deserialize, Clone, Debug)]
24pub struct User {
25    /// Unique user name.
26    pub name: String,
27    /// Argon2id PHC hash string (never plaintext).
28    pub password_hash: String,
29    /// Name of the role assigned to this user (a builtin role name).
30    pub role: String,
31}
32
33/// In-memory, serializable collection of users keyed by name.
34#[derive(Serialize, Deserialize, Clone, Debug, Default)]
35pub struct UserStore {
36    users: BTreeMap<String, User>,
37}
38
39impl UserStore {
40    /// Create an empty store.
41    pub fn new() -> Self {
42        UserStore {
43            users: BTreeMap::new(),
44        }
45    }
46
47    /// Create a new user.
48    ///
49    /// Errors if `name` already exists or `role` is not a known builtin.
50    /// The password is hashed with argon2id before storage.
51    pub fn create_user(&mut self, name: &str, password: &str, role: &str) -> Result<(), AuthError> {
52        if self.users.contains_key(name) {
53            return Err(AuthError::UserExists(name.to_string()));
54        }
55        if Role::builtin(role).is_none() {
56            return Err(AuthError::UnknownRole(role.to_string()));
57        }
58        // Hold the plaintext in a buffer that is zeroed on drop so the
59        // password does not linger in freed memory longer than necessary.
60        let secret = Zeroizing::new(password.to_string());
61        let password_hash = hash_password(&secret)?;
62        self.users.insert(
63            name.to_string(),
64            User {
65                name: name.to_string(),
66                password_hash,
67                role: role.to_string(),
68            },
69        );
70        Ok(())
71    }
72
73    /// Authenticate a user by name + candidate password.
74    ///
75    /// Returns `Some(&User)` only on a verified match. Unknown user or wrong
76    /// password both return `None`.
77    pub fn authenticate(&self, name: &str, candidate: &str) -> Option<&User> {
78        let user = self.users.get(name)?;
79        if verify_password(&user.password_hash, candidate) {
80            Some(user)
81        } else {
82            None
83        }
84    }
85
86    /// Reassign a user's role.
87    ///
88    /// Errors if the user is unknown or the role is not a known builtin.
89    pub fn set_role(&mut self, name: &str, role: &str) -> Result<(), AuthError> {
90        if Role::builtin(role).is_none() {
91            return Err(AuthError::UnknownRole(role.to_string()));
92        }
93        let user = self
94            .users
95            .get_mut(name)
96            .ok_or_else(|| AuthError::UnknownUser(name.to_string()))?;
97        user.role = role.to_string();
98        Ok(())
99    }
100
101    /// Replace a user's password with a new argon2id hash, preserving role.
102    ///
103    /// Errors if the user is unknown. The new plaintext is held in a buffer
104    /// that is zeroed on drop.
105    pub fn set_password(&mut self, name: &str, new_password: &str) -> Result<(), AuthError> {
106        let secret = Zeroizing::new(new_password.to_string());
107        let password_hash = hash_password(&secret)?;
108        let user = self
109            .users
110            .get_mut(name)
111            .ok_or_else(|| AuthError::UnknownUser(name.to_string()))?;
112        user.password_hash = password_hash;
113        Ok(())
114    }
115
116    /// Remove a user. Errors if the user is unknown.
117    pub fn delete_user(&mut self, name: &str) -> Result<(), AuthError> {
118        self.users
119            .remove(name)
120            .map(|_| ())
121            .ok_or_else(|| AuthError::UnknownUser(name.to_string()))
122    }
123
124    /// Number of users in the store.
125    pub fn len(&self) -> usize {
126        self.users.len()
127    }
128
129    /// Whether the store has no users. When empty, the server falls back to
130    /// the legacy shared-password authentication path.
131    pub fn is_empty(&self) -> bool {
132        self.users.is_empty()
133    }
134
135    /// List users as `(name, role)` pairs. Never exposes password hashes.
136    pub fn list_users(&self) -> Vec<(String, String)> {
137        self.users
138            .values()
139            .map(|u| (u.name.clone(), u.role.clone()))
140            .collect()
141    }
142
143    /// Persist the store to `dir/auth.json` as pretty JSON.
144    ///
145    /// On Unix the file is written with mode `0600`.
146    pub fn save(&self, dir: &Path) -> io::Result<()> {
147        let json = serde_json::to_string_pretty(self)
148            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
149        let path = dir.join(AUTH_FILE);
150        fs::write(&path, json)?;
151        #[cfg(unix)]
152        {
153            use std::os::unix::fs::PermissionsExt;
154            let perms = fs::Permissions::from_mode(0o600);
155            fs::set_permissions(&path, perms)?;
156        }
157        Ok(())
158    }
159
160    /// Load the store from `dir/auth.json`.
161    ///
162    /// If the file does not exist, returns an empty store.
163    pub fn load(dir: &Path) -> io::Result<Self> {
164        let path = dir.join(AUTH_FILE);
165        match fs::read_to_string(&path) {
166            Ok(json) => serde_json::from_str(&json)
167                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)),
168            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(UserStore::new()),
169            Err(e) => Err(e),
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn create_then_authenticate() {
180        let mut s = UserStore::new();
181        s.create_user("alice", "pw", "admin").unwrap();
182        assert!(s.authenticate("alice", "pw").is_some());
183        assert!(s.authenticate("alice", "bad").is_none());
184        assert!(s.authenticate("nobody", "pw").is_none());
185    }
186
187    #[test]
188    fn duplicate_create_rejected() {
189        let mut s = UserStore::new();
190        s.create_user("a", "pw", "readonly").unwrap();
191        assert!(matches!(
192            s.create_user("a", "pw2", "readonly"),
193            Err(AuthError::UserExists(_))
194        ));
195    }
196
197    #[test]
198    fn empty_and_len_track_users() {
199        let mut s = UserStore::new();
200        assert!(s.is_empty());
201        assert_eq!(s.len(), 0);
202        s.create_user("alice", "pw", "readwrite").unwrap();
203        assert!(!s.is_empty());
204        assert_eq!(s.len(), 1);
205        s.create_user("bob", "pw", "readonly").unwrap();
206        assert_eq!(s.len(), 2);
207        s.delete_user("alice").unwrap();
208        assert_eq!(s.len(), 1);
209        assert!(!s.is_empty());
210        s.delete_user("bob").unwrap();
211        assert!(s.is_empty());
212    }
213
214    #[test]
215    fn set_password_changes_credential() {
216        let mut s = UserStore::new();
217        s.create_user("alice", "old", "readwrite").unwrap();
218        assert!(s.authenticate("alice", "old").is_some());
219        s.set_password("alice", "new").unwrap();
220        // Old password no longer works; new one does; role is preserved.
221        assert!(s.authenticate("alice", "old").is_none());
222        assert!(s.authenticate("alice", "new").is_some());
223        assert_eq!(s.authenticate("alice", "new").unwrap().role, "readwrite");
224    }
225
226    #[test]
227    fn set_password_unknown_user_rejected() {
228        let mut s = UserStore::new();
229        assert!(matches!(
230            s.set_password("ghost", "pw"),
231            Err(AuthError::UnknownUser(_))
232        ));
233    }
234
235    #[test]
236    fn create_with_unknown_role_rejected() {
237        let mut s = UserStore::new();
238        assert!(matches!(
239            s.create_user("a", "pw", "wizard"),
240            Err(AuthError::UnknownRole(_))
241        ));
242    }
243}