Skip to main content

opendev_http/
user_store.rs

1//! File-based user store for authentication.
2//!
3//! Provides a thread-safe, JSON-backed store for user accounts.
4//! Users are stored in `{storage_dir}/users.json` and accessed via
5//! username or UUID lookups.
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::sync::{Arc, RwLock};
10
11use chrono::Utc;
12use tracing::warn;
13use uuid::Uuid;
14
15use opendev_models::User;
16
17use crate::models::HttpError;
18
19/// Thread-safe, JSON-backed store for user accounts.
20///
21/// All mutations are persisted to disk immediately. Read operations
22/// use an in-memory cache protected by a `RwLock` so multiple readers
23/// can proceed concurrently.
24#[derive(Debug)]
25pub struct UserStore {
26    users_file: PathBuf,
27    users: Arc<RwLock<HashMap<String, User>>>,
28}
29
30impl UserStore {
31    /// Create a new user store backed by `{storage_dir}/users.json`.
32    ///
33    /// Creates the directory and file if they do not exist.
34    pub fn new(storage_dir: PathBuf) -> Result<Self, HttpError> {
35        let users_file = storage_dir.join("users.json");
36        let store = Self {
37            users_file,
38            users: Arc::new(RwLock::new(HashMap::new())),
39        };
40        store.load()?;
41        Ok(store)
42    }
43
44    /// Look up a user by username.
45    pub fn get_by_username(&self, username: &str) -> Option<User> {
46        let users = self.users.read().expect("RwLock poisoned");
47        users.get(username).cloned()
48    }
49
50    /// Look up a user by UUID.
51    pub fn get_by_id(&self, user_id: Uuid) -> Option<User> {
52        let users = self.users.read().expect("RwLock poisoned");
53        users.values().find(|u| u.id == user_id).cloned()
54    }
55
56    /// Create a new user account.
57    ///
58    /// Returns an error if the username is already taken.
59    pub fn create_user(
60        &self,
61        username: &str,
62        password_hash: &str,
63        email: Option<&str>,
64    ) -> Result<User, HttpError> {
65        let mut users = self.users.write().expect("RwLock poisoned");
66        if users.contains_key(username) {
67            return Err(HttpError::Other(format!(
68                "User already exists: {}",
69                username
70            )));
71        }
72        let mut user = User::new(username.to_string(), password_hash.to_string());
73        user.email = email.map(|s| s.to_string());
74        users.insert(username.to_string(), user.clone());
75        drop(users);
76        self.persist()?;
77        Ok(user)
78    }
79
80    /// Update an existing user record.
81    ///
82    /// The `updated_at` timestamp is set to now automatically.
83    pub fn update_user(&self, mut user: User) -> Result<(), HttpError> {
84        let mut users = self.users.write().expect("RwLock poisoned");
85        user.updated_at = Utc::now();
86        users.insert(user.username.clone(), user);
87        drop(users);
88        self.persist()
89    }
90
91    /// Delete a user by username.
92    ///
93    /// Returns `true` if the user existed and was removed.
94    pub fn delete_user(&self, username: &str) -> Result<bool, HttpError> {
95        let mut users = self.users.write().expect("RwLock poisoned");
96        let removed = users.remove(username).is_some();
97        drop(users);
98        if removed {
99            self.persist()?;
100        }
101        Ok(removed)
102    }
103
104    /// List all usernames in the store.
105    pub fn list_usernames(&self) -> Vec<String> {
106        let users = self.users.read().expect("RwLock poisoned");
107        users.keys().cloned().collect()
108    }
109
110    /// Return the total number of users.
111    pub fn count(&self) -> usize {
112        let users = self.users.read().expect("RwLock poisoned");
113        users.len()
114    }
115
116    /// Load users from the JSON file into memory.
117    fn load(&self) -> Result<(), HttpError> {
118        if !self.users_file.exists() {
119            // Create parent dirs and an empty file
120            if let Some(parent) = self.users_file.parent() {
121                std::fs::create_dir_all(parent)?;
122            }
123            // Write to temp file, then rename (atomic)
124            let tmp_path = self
125                .users_file
126                .with_extension(format!("tmp.{}", Uuid::new_v4()));
127
128            #[cfg(unix)]
129            {
130                use std::os::unix::fs::OpenOptionsExt;
131                let mut opts = std::fs::OpenOptions::new();
132                opts.write(true).create(true).truncate(true).mode(0o600);
133                std::io::Write::write_all(&mut opts.open(&tmp_path)?, b"{}")?;
134            }
135            #[cfg(not(unix))]
136            {
137                std::fs::write(&tmp_path, "{}")?;
138            }
139
140            std::fs::rename(&tmp_path, &self.users_file)?;
141            return Ok(());
142        }
143
144        // Verify and tighten permissions
145        #[cfg(unix)]
146        self.check_permissions();
147
148        match std::fs::read_to_string(&self.users_file) {
149            Ok(content) => {
150                let parsed: HashMap<String, User> =
151                    serde_json::from_str(&content).unwrap_or_else(|e| {
152                        warn!("Failed to parse users file {:?}: {}", self.users_file, e);
153                        HashMap::new()
154                    });
155                let mut users = self.users.write().expect("RwLock poisoned");
156                *users = parsed;
157            }
158            Err(e) => {
159                warn!("Failed to read users file {:?}: {}", self.users_file, e);
160            }
161        }
162        Ok(())
163    }
164
165    /// Persist the in-memory user map to disk.
166    fn persist(&self) -> Result<(), HttpError> {
167        let users = self.users.read().expect("RwLock poisoned");
168        let json = serde_json::to_string_pretty(&*users)?;
169        drop(users);
170
171        if let Some(parent) = self.users_file.parent() {
172            std::fs::create_dir_all(parent)?;
173        }
174
175        // Write to temp file, then rename (atomic)
176        let tmp_path = self
177            .users_file
178            .with_extension(format!("tmp.{}", Uuid::new_v4()));
179
180        #[cfg(unix)]
181        {
182            use std::os::unix::fs::OpenOptionsExt;
183            let mut opts = std::fs::OpenOptions::new();
184            opts.write(true).create(true).truncate(true).mode(0o600);
185            std::io::Write::write_all(&mut opts.open(&tmp_path)?, json.as_bytes())?;
186        }
187        #[cfg(not(unix))]
188        {
189            std::fs::write(&tmp_path, json)?;
190        }
191
192        std::fs::rename(&tmp_path, &self.users_file)?;
193        Ok(())
194    }
195
196    /// Check and tighten file permissions on Unix.
197    #[cfg(unix)]
198    fn check_permissions(&self) {
199        use std::os::unix::fs::PermissionsExt;
200        if let Ok(meta) = std::fs::metadata(&self.users_file) {
201            let mode = meta.permissions().mode() & 0o777;
202            if mode & 0o077 != 0 {
203                warn!(
204                    "User store file {:?} has loose permissions ({:o}). Tightening to 0600.",
205                    self.users_file, mode
206                );
207                let _ = std::fs::set_permissions(
208                    &self.users_file,
209                    std::fs::Permissions::from_mode(0o600),
210                );
211            }
212        }
213    }
214}
215
216#[cfg(test)]
217#[path = "user_store_tests.rs"]
218mod tests;