Skip to main content

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