Skip to main content

engram/auth/
users.rs

1//! User management
2
3use crate::error::{EngramError, Result};
4use chrono::{DateTime, Utc};
5use rusqlite::{params, Connection, OptionalExtension};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use uuid::Uuid;
9
10/// User identifier
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub struct UserId(String);
13
14impl UserId {
15    /// Create a new random user ID
16    pub fn new() -> Self {
17        Self(Uuid::new_v4().to_string())
18    }
19
20    /// Create from string
21    pub fn from_string(s: impl Into<String>) -> Self {
22        Self(s.into())
23    }
24
25    /// System user ID
26    pub fn system() -> Self {
27        Self("system".to_string())
28    }
29
30    /// Anonymous user ID
31    pub fn anonymous() -> Self {
32        Self("anonymous".to_string())
33    }
34
35    /// Get the string value
36    pub fn as_str(&self) -> &str {
37        &self.0
38    }
39}
40
41impl Default for UserId {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl std::fmt::Display for UserId {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}", self.0)
50    }
51}
52
53/// User record
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct User {
56    pub id: UserId,
57    pub username: String,
58    pub display_name: Option<String>,
59    pub email: Option<String>,
60    pub is_active: bool,
61    pub is_admin: bool,
62    pub created_at: DateTime<Utc>,
63    pub updated_at: DateTime<Utc>,
64}
65
66impl User {
67    /// Create a new user
68    pub fn new(username: impl Into<String>) -> Self {
69        let now = Utc::now();
70        Self {
71            id: UserId::new(),
72            username: username.into(),
73            display_name: None,
74            email: None,
75            is_active: true,
76            is_admin: false,
77            created_at: now,
78            updated_at: now,
79        }
80    }
81
82    /// Set display name
83    pub fn with_display_name(mut self, name: impl Into<String>) -> Self {
84        self.display_name = Some(name.into());
85        self
86    }
87
88    /// Set email
89    pub fn with_email(mut self, email: impl Into<String>) -> Self {
90        self.email = Some(email.into());
91        self
92    }
93
94    /// Set as admin
95    pub fn with_admin(mut self, is_admin: bool) -> Self {
96        self.is_admin = is_admin;
97        self
98    }
99}
100
101/// User management operations
102pub struct UserManager<'a> {
103    conn: &'a Connection,
104}
105
106impl<'a> UserManager<'a> {
107    /// Create a new user manager
108    pub fn new(conn: &'a Connection) -> Self {
109        Self { conn }
110    }
111
112    /// Create a new user
113    pub fn create_user(&self, user: &User, password: Option<&str>) -> Result<()> {
114        let password_hash = password.map(hash_password);
115
116        self.conn.execute(
117            r#"
118            INSERT INTO users (id, username, display_name, email, password_hash, is_active, is_admin, created_at, updated_at)
119            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
120            "#,
121            params![
122                user.id.as_str(),
123                user.username,
124                user.display_name,
125                user.email,
126                password_hash,
127                user.is_active,
128                user.is_admin,
129                user.created_at.to_rfc3339(),
130                user.updated_at.to_rfc3339(),
131            ],
132        )?;
133        Ok(())
134    }
135
136    /// Get user by ID
137    pub fn get_user(&self, id: &UserId) -> Result<Option<User>> {
138        self.conn
139            .query_row(
140                r#"
141                SELECT id, username, display_name, email, is_active, is_admin, created_at, updated_at
142                FROM users WHERE id = ?1
143                "#,
144                params![id.as_str()],
145                |row| {
146                    Ok(User {
147                        id: UserId::from_string(row.get::<_, String>(0)?),
148                        username: row.get(1)?,
149                        display_name: row.get(2)?,
150                        email: row.get(3)?,
151                        is_active: row.get(4)?,
152                        is_admin: row.get(5)?,
153                        created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)
154                            .map(|dt| dt.with_timezone(&Utc))
155                            .unwrap_or_else(|_| Utc::now()),
156                        updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
157                            .map(|dt| dt.with_timezone(&Utc))
158                            .unwrap_or_else(|_| Utc::now()),
159                    })
160                },
161            )
162            .optional()
163            .map_err(EngramError::from)
164    }
165
166    /// Get user by username
167    pub fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
168        self.conn
169            .query_row(
170                r#"
171                SELECT id, username, display_name, email, is_active, is_admin, created_at, updated_at
172                FROM users WHERE username = ?1
173                "#,
174                params![username],
175                |row| {
176                    Ok(User {
177                        id: UserId::from_string(row.get::<_, String>(0)?),
178                        username: row.get(1)?,
179                        display_name: row.get(2)?,
180                        email: row.get(3)?,
181                        is_active: row.get(4)?,
182                        is_admin: row.get(5)?,
183                        created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)
184                            .map(|dt| dt.with_timezone(&Utc))
185                            .unwrap_or_else(|_| Utc::now()),
186                        updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
187                            .map(|dt| dt.with_timezone(&Utc))
188                            .unwrap_or_else(|_| Utc::now()),
189                    })
190                },
191            )
192            .optional()
193            .map_err(EngramError::from)
194    }
195
196    /// Verify user password
197    pub fn verify_password(&self, username: &str, password: &str) -> Result<Option<User>> {
198        let result: Option<(String, Option<String>)> = self
199            .conn
200            .query_row(
201                "SELECT id, password_hash FROM users WHERE username = ?1 AND is_active = 1",
202                params![username],
203                |row| Ok((row.get(0)?, row.get(1)?)),
204            )
205            .optional()?;
206
207        if let Some((id, Some(stored_hash))) = result {
208            if verify_password(password, &stored_hash) {
209                return self.get_user(&UserId::from_string(id));
210            }
211        }
212        Ok(None)
213    }
214
215    /// Update user
216    pub fn update_user(&self, user: &User) -> Result<()> {
217        self.conn.execute(
218            r#"
219            UPDATE users SET
220                username = ?2,
221                display_name = ?3,
222                email = ?4,
223                is_active = ?5,
224                is_admin = ?6,
225                updated_at = ?7
226            WHERE id = ?1
227            "#,
228            params![
229                user.id.as_str(),
230                user.username,
231                user.display_name,
232                user.email,
233                user.is_active,
234                user.is_admin,
235                Utc::now().to_rfc3339(),
236            ],
237        )?;
238        Ok(())
239    }
240
241    /// Delete user
242    pub fn delete_user(&self, id: &UserId) -> Result<bool> {
243        let deleted = self
244            .conn
245            .execute("DELETE FROM users WHERE id = ?1", params![id.as_str()])?;
246        Ok(deleted > 0)
247    }
248
249    /// List all users
250    pub fn list_users(&self, include_inactive: bool) -> Result<Vec<User>> {
251        let sql = if include_inactive {
252            "SELECT id, username, display_name, email, is_active, is_admin, created_at, updated_at FROM users ORDER BY created_at DESC"
253        } else {
254            "SELECT id, username, display_name, email, is_active, is_admin, created_at, updated_at FROM users WHERE is_active = 1 ORDER BY created_at DESC"
255        };
256
257        let mut stmt = self.conn.prepare(sql)?;
258        let users = stmt
259            .query_map([], |row| {
260                Ok(User {
261                    id: UserId::from_string(row.get::<_, String>(0)?),
262                    username: row.get(1)?,
263                    display_name: row.get(2)?,
264                    email: row.get(3)?,
265                    is_active: row.get(4)?,
266                    is_admin: row.get(5)?,
267                    created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)
268                        .map(|dt| dt.with_timezone(&Utc))
269                        .unwrap_or_else(|_| Utc::now()),
270                    updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(7)?)
271                        .map(|dt| dt.with_timezone(&Utc))
272                        .unwrap_or_else(|_| Utc::now()),
273                })
274            })?
275            .collect::<std::result::Result<Vec<_>, _>>()?;
276        Ok(users)
277    }
278}
279
280/// Hash a password using SHA-256 (in production, use bcrypt or argon2)
281fn hash_password(password: &str) -> String {
282    let mut hasher = Sha256::new();
283    hasher.update(password.as_bytes());
284    hex::encode(hasher.finalize())
285}
286
287/// Verify a password against a hash
288fn verify_password(password: &str, hash: &str) -> bool {
289    hash_password(password) == hash
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::auth::init_auth_tables;
296
297    fn setup_db() -> Connection {
298        let conn = Connection::open_in_memory().unwrap();
299        init_auth_tables(&conn).unwrap();
300        conn
301    }
302
303    #[test]
304    fn test_create_and_get_user() {
305        let conn = setup_db();
306        let manager = UserManager::new(&conn);
307
308        let user = User::new("testuser")
309            .with_display_name("Test User")
310            .with_email("test@example.com");
311
312        manager.create_user(&user, Some("password123")).unwrap();
313
314        let fetched = manager.get_user(&user.id).unwrap().unwrap();
315        assert_eq!(fetched.username, "testuser");
316        assert_eq!(fetched.display_name, Some("Test User".to_string()));
317        assert_eq!(fetched.email, Some("test@example.com".to_string()));
318    }
319
320    #[test]
321    fn test_get_user_by_username() {
322        let conn = setup_db();
323        let manager = UserManager::new(&conn);
324
325        let user = User::new("findme");
326        manager.create_user(&user, None).unwrap();
327
328        let fetched = manager.get_user_by_username("findme").unwrap().unwrap();
329        assert_eq!(fetched.id, user.id);
330    }
331
332    #[test]
333    fn test_verify_password() {
334        let conn = setup_db();
335        let manager = UserManager::new(&conn);
336
337        let user = User::new("authuser");
338        manager.create_user(&user, Some("secret123")).unwrap();
339
340        let verified = manager.verify_password("authuser", "secret123").unwrap();
341        assert!(verified.is_some());
342
343        let wrong = manager
344            .verify_password("authuser", "wrongpassword")
345            .unwrap();
346        assert!(wrong.is_none());
347    }
348
349    #[test]
350    fn test_update_user() {
351        let conn = setup_db();
352        let manager = UserManager::new(&conn);
353
354        let mut user = User::new("updateme");
355        manager.create_user(&user, None).unwrap();
356
357        user.display_name = Some("Updated Name".to_string());
358        manager.update_user(&user).unwrap();
359
360        let fetched = manager.get_user(&user.id).unwrap().unwrap();
361        assert_eq!(fetched.display_name, Some("Updated Name".to_string()));
362    }
363
364    #[test]
365    fn test_delete_user() {
366        let conn = setup_db();
367        let manager = UserManager::new(&conn);
368
369        let user = User::new("deleteme");
370        manager.create_user(&user, None).unwrap();
371
372        let deleted = manager.delete_user(&user.id).unwrap();
373        assert!(deleted);
374
375        let fetched = manager.get_user(&user.id).unwrap();
376        assert!(fetched.is_none());
377    }
378
379    #[test]
380    fn test_list_users() {
381        let conn = setup_db();
382        let manager = UserManager::new(&conn);
383
384        let user1 = User::new("user1");
385        let mut user2 = User::new("user2");
386        user2.is_active = false;
387
388        manager.create_user(&user1, None).unwrap();
389        manager.create_user(&user2, None).unwrap();
390
391        let active = manager.list_users(false).unwrap();
392        assert_eq!(active.len(), 1);
393
394        let all = manager.list_users(true).unwrap();
395        assert_eq!(all.len(), 2);
396    }
397}