xapi_rs/lrs/
user.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Data structures and functions to facilitate managing users of this server
4//! as well as enforcing access authentication, when enabled, to its resources.
5
6use crate::{
7    Agent, MyError,
8    config::config,
9    db::user::{TUser, find_active_user},
10    lrs::{DB, role::Role},
11};
12use base64::{Engine, prelude::BASE64_STANDARD};
13use chrono::{DateTime, Utc};
14use core::fmt;
15use lru::LruCache;
16use rocket::{
17    Request, State,
18    http::{Status, hyper::header},
19    request::{FromRequest, Outcome},
20};
21use serde::{Deserialize, Serialize};
22use serde_with::{FromInto, serde_as};
23use std::sync::OnceLock;
24use tokio::sync::Mutex;
25use tracing::{debug, error, info};
26
27/// Representation of a user that is subject to authentication and authorization.
28#[serde_as]
29#[derive(Debug, Deserialize, Serialize)]
30pub struct User {
31    /// Row ID uniquely identifying this instance.
32    pub id: i32,
33    /// Whether this is active (TRUE) or not (FALSE).
34    pub enabled: bool,
35    /// User's IFI.
36    pub email: String,
37    /// Current role.
38    #[serde_as(as = "FromInto<u16>")]
39    pub role: Role,
40    /// Row ID of the User that currently manages this.
41    pub manager_id: i32,
42    /// When this was created.
43    pub created: DateTime<Utc>,
44    /// When this was last updated.
45    pub updated: DateTime<Utc>,
46}
47
48impl Default for User {
49    /// Return the hard-wired single User who will also act as the Authority
50    /// Agent for submitted Statements in LEGACY and AUTH modes.
51    fn default() -> Self {
52        Self {
53            id: 0,
54            email: config().root_email.clone(),
55            enabled: true,
56            role: Role::Root,
57            manager_id: 0,
58            created: Utc::now(),
59            updated: Utc::now(),
60        }
61    }
62}
63
64impl fmt::Display for User {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match (&self.role, &self.enabled) {
67            (Role::Guest, _) => write!(f, "guest <{}>", self.email),
68            (Role::User, true) => write!(f, "xapi+ <{}>", self.email),
69            (Role::User, false) => write!(f, "xapi- <{}>", self.email),
70            (Role::AuthUser, true) => write!(f, "auth+ <{}>", self.email),
71            (Role::AuthUser, false) => write!(f, "auth- <{}>", self.email),
72            (Role::Admin, true) => write!(f, "admin+ <{}>", self.email),
73            (Role::Admin, false) => write!(f, "admin- <{}>", self.email),
74            (Role::Root, _) => write!(f, "root"),
75        }
76    }
77}
78
79impl From<TUser> for User {
80    /// Construct a User from its corresponding DB table row.
81    fn from(row: TUser) -> Self {
82        User {
83            id: row.id,
84            email: row.email,
85            enabled: row.enabled,
86            role: Role::from(row.role),
87            manager_id: row.manager_id,
88            created: row.created,
89            updated: row.updated,
90        }
91    }
92}
93
94/// Representation of a cached User. Mirrors all but timestamp fields.
95#[derive(Debug)]
96struct CachedUser {
97    id: i32,
98    email: String,
99    enabled: bool,
100    role: Role,
101    manager_id: i32,
102}
103
104impl From<&CachedUser> for User {
105    /// Reconstruct a User from a cached projection.
106    fn from(value: &CachedUser) -> Self {
107        User {
108            id: value.id,
109            email: value.email.to_owned(),
110            enabled: value.enabled,
111            role: value.role,
112            manager_id: value.manager_id,
113            ..Default::default()
114        }
115    }
116}
117
118impl From<&User> for CachedUser {
119    /// Map a User to a representation suited for our cache.
120    fn from(user: &User) -> Self {
121        CachedUser {
122            id: user.id,
123            email: user.email.clone(),
124            enabled: user.enabled,
125            role: user.role,
126            manager_id: user.manager_id,
127        }
128    }
129}
130
131impl User {
132    /// Compute Basic Authentication credentials from given email and password.
133    pub(crate) fn credentials_from(email: &str, password: &str) -> u32 {
134        let basic = format!("{email}:{password}");
135        let encoded = BASE64_STANDARD.encode(basic);
136        fxhash::hash32(&encoded)
137    }
138
139    /// Clears the cache forcing user DB lookup upon receiving future requests.
140    pub(crate) async fn clear_cache() {
141        let mut cache = cached_users().lock().await;
142        cache.clear();
143        info!("Cache cleared")
144    }
145
146    /// Create a new enabled user from an email address string.
147    #[cfg(test)]
148    pub(crate) fn with_email(email: &str) -> Self {
149        Self {
150            email: email.to_owned(),
151            ..Default::default()
152        }
153    }
154
155    /// Return an [Agent] representing this user.
156    pub(crate) fn as_agent(&self) -> Agent {
157        Agent::builder().mbox(&self.email).unwrap().build().unwrap()
158    }
159
160    /// Return an [Agent] acting as the Authority vouching for this user's data.
161    pub(crate) fn authority(&self) -> Agent {
162        match config().mode {
163            // in "user" mode the user themselves act as the Authority.
164            crate::Mode::User => self.as_agent(),
165            // in all other modes (i.e. "legacy" and "auth"), the root's email
166            // is the Authority Agent's IFI.
167            _ => Agent::builder()
168                .mbox(&config().root_email)
169                .unwrap()
170                .build()
171                .unwrap(),
172        }
173    }
174
175    /// Check if this user is enabled or not. If is not enabled return
176    /// an Error wrapping an HTTP 403 Status.
177    fn check_is_enabled(&self) -> Result<(), MyError> {
178        if !self.enabled {
179            Err(MyError::HTTP {
180                status: Status::Forbidden,
181                info: format!("User {self} is NOT active").into(),
182            })
183        } else {
184            Ok(())
185        }
186    }
187
188    pub(crate) fn can_use_xapi(&self) -> Result<(), MyError> {
189        // to be sure, to be sure...
190        self.check_is_enabled()?;
191        if !matches!(self.role, Role::Root | Role::User | Role::AuthUser) {
192            Err(MyError::HTTP {
193                status: Status::Forbidden,
194                info: format!("User {self} is NOT authorized to use xAPI").into(),
195            })
196        } else {
197            Ok(())
198        }
199    }
200
201    pub(crate) fn can_authorize_statement(&self) -> Result<(), MyError> {
202        self.check_is_enabled()?;
203        if !matches!(self.role, Role::Root | Role::AuthUser) {
204            Err(MyError::HTTP {
205                status: Status::Forbidden,
206                info: format!("User {self} is NOT allowed to authorize Statements").into(),
207            })
208        } else {
209            Ok(())
210        }
211    }
212
213    pub(crate) fn can_use_verbs(&self) -> Result<(), MyError> {
214        self.check_is_enabled()?;
215        if !matches!(self.role, Role::Root | Role::Admin) {
216            Err(MyError::HTTP {
217                status: Status::Forbidden,
218                info: format!("User {self} is NOT authorized to use verbs").into(),
219            })
220        } else {
221            Ok(())
222        }
223    }
224
225    pub(crate) fn can_manage_users(&self) -> Result<(), MyError> {
226        self.check_is_enabled()?;
227        if !matches!(self.role, Role::Root | Role::Admin) {
228            Err(MyError::HTTP {
229                status: Status::Forbidden,
230                info: format!("User {self} is NOT authorized to manage users").into(),
231            })
232        } else {
233            Ok(())
234        }
235    }
236
237    pub(crate) fn is_root(&self) -> bool {
238        matches!(self.role, Role::Root)
239    }
240
241    pub(crate) fn is_admin(&self) -> bool {
242        matches!(self.role, Role::Admin)
243    }
244
245    /// If this user is cached, evict it...
246    pub(crate) async fn uncache(&self) {
247        let mut cache = cached_users().lock().await;
248        for (&k, v) in cache.iter() {
249            if v.id == self.id {
250                cache.pop(&k);
251                info!("Evicted user #{}", self.id);
252                break;
253            }
254        }
255    }
256}
257
258// for better performance, we cache Users in an an LRU in-memory store.
259static CACHED_USERS: OnceLock<Mutex<LruCache<u32, CachedUser>>> = OnceLock::new();
260fn cached_users() -> &'static Mutex<LruCache<u32, CachedUser>> {
261    CACHED_USERS.get_or_init(|| Mutex::new(LruCache::new(config().user_cache_len)))
262}
263
264async fn find_cached_user(key: &u32) -> Option<User> {
265    let mut cache = cached_users().lock().await;
266    cache.get(key).map(User::from)
267}
268
269async fn cache_user(key: u32, user: &User) {
270    let mut cache = cached_users().lock().await;
271    cache.put(key, CachedUser::from(user));
272}
273
274#[rocket::async_trait]
275impl<'r> FromRequest<'r> for User {
276    type Error = MyError;
277
278    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
279        // which mode are we running?
280        match config().mode {
281            crate::Mode::Legacy => Outcome::Success(User::default()),
282            _ => {
283                // enforce BA access but...
284                // only use authenticated User as Authority if mode is "user"
285                match req.headers().get_one(header::AUTHORIZATION.as_str()) {
286                    Some(basic_auth) => {
287                        let trimmed = basic_auth.trim();
288                        if trimmed[..6].to_lowercase() != *"basic " {
289                            let msg = "Invalid Authorization header";
290                            error!("Failed: {}", msg);
291                            Outcome::Error((Status::BadRequest, MyError::Runtime(msg.into())))
292                        } else {
293                            // NOTE (rsn) 20250103 - i don't store clear passwords.
294                            // instead i compute a 32-bit hash from their BA token.
295                            let token = trimmed[6..].trim();
296                            let credentials = fxhash::hash32(token);
297                            // check first if we have this in our LRU cache...
298                            match find_cached_user(&credentials).await {
299                                Some(x) => Outcome::Success(x),
300                                None => {
301                                    // TODO (rsn) 20250106 - store that in an atomic
302                                    // counter and include it in the server metrics...
303                                    debug!("Cache miss...");
304                                    match req.guard::<&State<DB>>().await {
305                                        Outcome::Success(db) => {
306                                            let conn = db.pool();
307                                            match find_active_user(conn, credentials).await {
308                                                Ok(None) => {
309                                                    error!("Unknown user");
310                                                    Outcome::Forward(Status::Unauthorized)
311                                                }
312                                                Ok(Some(x)) => {
313                                                    debug!("User = {}", x);
314                                                    cache_user(credentials, &x).await;
315                                                    Outcome::Success(x)
316                                                }
317                                                Err(x) => {
318                                                    error!("Failed: {}", x);
319                                                    Outcome::Forward(Status::Unauthorized)
320                                                }
321                                            }
322                                        }
323                                        _ => {
324                                            let msg =
325                                                "Unable to get DB pool to check user credentials";
326                                            error!("Failed: {}", msg);
327                                            return Outcome::Error((
328                                                Status::BadRequest,
329                                                MyError::Runtime(msg.into()),
330                                            ));
331                                        }
332                                    }
333                                }
334                            }
335                        }
336                    }
337                    None => {
338                        let msg = "Unauthorized access";
339                        error!("Failed: {}", msg);
340                        Outcome::Forward(Status::Unauthorized)
341                    }
342                }
343            }
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::lrs::TEST_USER_PLAIN_TOKEN;
352    use tracing_test::traced_test;
353
354    #[test]
355    fn test_test_user_credentials() {
356        let plain = BASE64_STANDARD.encode(TEST_USER_PLAIN_TOKEN);
357        assert_eq!(plain, "dGVzdEBteS54YXBpLm5ldDo=");
358        let credentials = fxhash::hash32(plain.as_bytes());
359        assert_eq!(credentials, 3793911390);
360        let credentials = fxhash::hash32(&plain);
361        assert_eq!(credentials, 2175704399);
362    }
363
364    #[test]
365    fn test_class_methods() {
366        let credentials = User::credentials_from("test@my.xapi.net", "");
367        assert_eq!(credentials, 2175704399);
368    }
369
370    #[traced_test]
371    #[tokio::test]
372    async fn test_cache_eviction() {
373        let u1 = User {
374            id: 100,
375            enabled: true,
376            email: "nobody@nowhere".to_owned(),
377            role: Role::User,
378            ..Default::default()
379        };
380        let u2 = User {
381            id: 200,
382            enabled: true,
383            email: "anybody@nowhere".to_owned(),
384            role: Role::User,
385            ..Default::default()
386        };
387
388        cache_user(10, &u1).await;
389        cache_user(20, &u2).await;
390
391        // wrap in a block to drop+unlock `c` on exist...
392        {
393            let c = cached_users().lock().await;
394            assert_eq!(c.len(), 2);
395        }
396
397        u1.uncache().await;
398        {
399            let c = cached_users().lock().await;
400            assert_eq!(c.len(), 1);
401        }
402
403        u2.uncache().await;
404        let c = cached_users().lock().await;
405        assert!(c.is_empty())
406    }
407
408    #[traced_test]
409    #[tokio::test]
410    async fn test_cache_clearing() {
411        let u1 = User {
412            id: 100,
413            enabled: true,
414            email: "nobody@nowhere".to_owned(),
415            role: Role::User,
416            ..Default::default()
417        };
418        let u2 = User {
419            id: 200,
420            enabled: true,
421            email: "anybody@nowhere".to_owned(),
422            role: Role::User,
423            ..Default::default()
424        };
425
426        cache_user(10, &u1).await;
427        cache_user(20, &u2).await;
428
429        User::clear_cache().await;
430
431        let c = cached_users().lock().await;
432        assert!(c.is_empty())
433    }
434}