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