Skip to main content

laterite_auth/
service.rs

1//! The authentication and authorization service.
2//!
3//! Composes the store, password, and permission layers into the flows the
4//! admin surface calls: `authenticate` (throttle, verify, issue session, log),
5//! `verify_session` (resolve a token to an identity), and `logout`.
6
7use std::fmt::Write as _;
8use std::time::Duration;
9
10use chrono::{DateTime, Utc};
11use rand::RngCore;
12use serde::Deserialize;
13use sha2::{Digest, Sha256};
14
15use laterite_core::Db;
16
17use crate::error::AuthError;
18use crate::models::{AccessEvent, BackendUser};
19use crate::password;
20use crate::permission::PermissionSet;
21use crate::store;
22
23/// Tunable auth policy. Loadable from a config section (all keys optional; an unset
24/// key keeps its default):
25///
26/// ```toml
27/// [auth]
28/// session_ttl_secs = 43200
29/// max_failures = 5
30/// failure_window_secs = 900
31/// ```
32#[derive(Debug, Clone, Deserialize)]
33#[serde(default)]
34pub struct AuthConfig {
35    /// How long an issued session remains valid.
36    #[serde(rename = "session_ttl_secs", deserialize_with = "de_secs")]
37    pub session_ttl: Duration,
38    /// Failed attempts within `failure_window` before a username is locked out.
39    pub max_failures: i64,
40    /// The window over which failed attempts are counted.
41    #[serde(rename = "failure_window_secs", deserialize_with = "de_secs")]
42    pub failure_window: Duration,
43}
44
45impl Default for AuthConfig {
46    fn default() -> Self {
47        Self {
48            session_ttl: Duration::from_secs(60 * 60 * 12),
49            max_failures: 5,
50            failure_window: Duration::from_secs(60 * 15),
51        }
52    }
53}
54
55/// Deserializes a whole-second count into a `Duration`.
56fn de_secs<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
57    Ok(Duration::from_secs(u64::deserialize(de)?))
58}
59
60#[cfg(test)]
61mod auth_config_tests {
62    use super::AuthConfig;
63    use std::time::Duration;
64
65    #[test]
66    fn unset_keys_keep_defaults() {
67        let cfg: AuthConfig = serde_json::from_str(r#"{"max_failures": 3}"#).unwrap();
68        assert_eq!(cfg.max_failures, 3);
69        assert_eq!(cfg.session_ttl, Duration::from_secs(60 * 60 * 12));
70        assert_eq!(cfg.failure_window, Duration::from_secs(60 * 15));
71    }
72
73    #[test]
74    fn seconds_map_to_durations() {
75        let cfg: AuthConfig = serde_json::from_str(
76            r#"{"session_ttl_secs": 3600, "max_failures": 7, "failure_window_secs": 120}"#,
77        )
78        .unwrap();
79        assert_eq!(cfg.session_ttl, Duration::from_secs(3600));
80        assert_eq!(cfg.max_failures, 7);
81        assert_eq!(cfg.failure_window, Duration::from_secs(120));
82    }
83}
84
85/// Per-request context recorded in the access log.
86#[derive(Debug, Clone, Default)]
87pub struct RequestContext {
88    pub ip_address: Option<String>,
89    pub user_agent: Option<String>,
90}
91
92/// A freshly issued session. `token` is the raw bearer value for the client
93/// cookie; only its hash is persisted.
94#[derive(Debug, Clone)]
95pub struct IssuedSession {
96    pub token: String,
97    pub expires_at: DateTime<Utc>,
98}
99
100/// An authenticated backend user together with the permissions in force.
101#[derive(Debug, Clone)]
102pub struct AuthenticatedUser {
103    pub user: BackendUser,
104    pub permissions: PermissionSet,
105}
106
107/// A resolved live session: the authenticated identity plus the opaque data
108/// blob the surface stored on it (`None` until the surface writes one). Auth
109/// does not interpret the blob.
110#[derive(Debug, Clone)]
111pub struct ResolvedSession {
112    pub identity: AuthenticatedUser,
113    pub data: Option<String>,
114}
115
116impl AuthenticatedUser {
117    /// Whether this identity holds `permission`.
118    pub fn allows(&self, permission: &str) -> bool {
119        self.permissions.allows(permission)
120    }
121
122    /// Returns an error unless this identity holds `permission`.
123    pub fn require(&self, permission: &str) -> Result<(), AuthError> {
124        if self.allows(permission) {
125            Ok(())
126        } else {
127            Err(AuthError::PermissionDenied(permission.to_string()))
128        }
129    }
130}
131
132/// The details for creating a backend operator. Used by the CLI and the
133/// first-run setup screen so account creation has one code path.
134#[derive(Debug, Clone)]
135pub struct NewOperator<'a> {
136    pub username: &'a str,
137    pub email: &'a str,
138    pub first_name: &'a str,
139    pub last_name: Option<&'a str>,
140    pub password: &'a str,
141    /// The operator's display timezone (an IANA name), or `None` to inherit the
142    /// deployment default.
143    pub timezone: Option<&'a str>,
144}
145
146/// One append-only audit entry: who did what, to which target. Actions are
147/// dot-keyed (`backend.role.update`); the username is snapshotted so the entry
148/// stays legible after the actor's account is removed.
149pub struct AuditEntry<'a> {
150    /// The acting operator's id, or `None` for a system action.
151    pub actor_user_id: Option<i64>,
152    /// The acting operator's username, recorded verbatim.
153    pub actor_username: &'a str,
154    /// A dot-keyed action, e.g. `backend.plugin.disable`.
155    pub action: &'a str,
156    /// What was acted on (e.g. `backend_role`) and its id; both optional.
157    pub target_type: Option<&'a str>,
158    pub target_id: Option<&'a str>,
159    /// Optional JSON describing the change.
160    pub detail: Option<&'a str>,
161}
162
163/// The auth service. Cheap to clone: it holds a database handle and config.
164#[derive(Clone)]
165pub struct AuthService {
166    db: Db,
167    config: AuthConfig,
168}
169
170impl AuthService {
171    pub fn new(db: Db, config: AuthConfig) -> Self {
172        Self { db, config }
173    }
174
175    /// Verifies a username and password, and on success issues a session.
176    ///
177    /// Failures are throttled per username and every outcome is logged. The
178    /// error deliberately does not reveal whether the username exists.
179    pub async fn authenticate(
180        &self,
181        username: &str,
182        password: &str,
183        ctx: &RequestContext,
184    ) -> Result<IssuedSession, AuthError> {
185        let now = Utc::now();
186        let since = now - chrono_from_std(self.config.failure_window);
187
188        if store::count_recent_failures(&self.db, username, since).await?
189            >= self.config.max_failures
190        {
191            self.log(None, username, AccessEvent::LockedOut, ctx)
192                .await?;
193            return Err(AuthError::TooManyAttempts);
194        }
195
196        let user = match store::find_user_by_username(&self.db, username).await? {
197            Some(user) => user,
198            None => {
199                self.log(None, username, AccessEvent::LoginFailure, ctx)
200                    .await?;
201                return Err(AuthError::InvalidCredentials);
202            }
203        };
204
205        if !password::verify_password(password, &user.password_hash)? {
206            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
207                .await?;
208            return Err(AuthError::InvalidCredentials);
209        }
210
211        if !user.is_active {
212            self.log(Some(user.id), username, AccessEvent::LoginFailure, ctx)
213                .await?;
214            return Err(AuthError::InactiveAccount);
215        }
216
217        let token = generate_token();
218        let expires_at = now + chrono_from_std(self.config.session_ttl);
219        store::insert_session(&self.db, &hash_token(&token), user.id, expires_at).await?;
220        self.log(Some(user.id), username, AccessEvent::LoginSuccess, ctx)
221            .await?;
222
223        Ok(IssuedSession { token, expires_at })
224    }
225
226    /// Resolves a raw session token to an identity and its stored data blob,
227    /// refreshing last-seen. Expired sessions, and sessions whose user was
228    /// disabled or removed, resolve to [`AuthError::SessionInvalid`]. The blob
229    /// is read in the same query as the session, adding no round-trip.
230    pub async fn resolve_session(&self, token: &str) -> Result<ResolvedSession, AuthError> {
231        let token_hash = hash_token(token);
232        let now = Utc::now();
233
234        let session = store::find_valid_session(&self.db, &token_hash, now)
235            .await?
236            .ok_or(AuthError::SessionInvalid)?;
237        let user = store::find_active_user_by_id(&self.db, session.user_id)
238            .await?
239            .ok_or(AuthError::SessionInvalid)?;
240        store::touch_session(&self.db, &token_hash, now).await?;
241
242        let grants = store::load_role_permissions(&self.db, user.id)
243            .await?
244            .into_iter()
245            .flatten();
246        // Split the user's per-permission overrides into allow (1) and deny (-1),
247        // which take precedence over the role grants.
248        let overrides = store::load_user_permission_overrides(&self.db, user.id).await?;
249        let (mut allow, mut deny) = (Vec::new(), Vec::new());
250        for (code, decision) in overrides {
251            match decision.signum() {
252                1 => allow.push(code),
253                -1 => deny.push(code),
254                _ => {}
255            }
256        }
257        let permissions = PermissionSet::with_overrides(user.is_superuser, grants, allow, deny);
258
259        Ok(ResolvedSession {
260            identity: AuthenticatedUser { user, permissions },
261            data: session.data,
262        })
263    }
264
265    /// Resolves a raw session token to an identity. A thin wrapper over
266    /// [`AuthService::resolve_session`] for callers that need only the identity.
267    pub async fn verify_session(&self, token: &str) -> Result<AuthenticatedUser, AuthError> {
268        Ok(self.resolve_session(token).await?.identity)
269    }
270
271    /// Overwrites the opaque per-session data blob (the surface's serialised
272    /// state, e.g. CSRF token + flash). The surface writes only when its blob
273    /// changed, so an unchanged request adds no write.
274    pub async fn set_session_data(&self, token: &str, data: &str) -> Result<(), AuthError> {
275        store::set_session_data(&self.db, &hash_token(token), data).await
276    }
277
278    /// Invalidates a session. Unknown tokens are a no-op.
279    pub async fn logout(&self, token: &str) -> Result<(), AuthError> {
280        store::delete_session(&self.db, &hash_token(token)).await
281    }
282
283    /// Persists an operator's own display timezone. `Some(name)` sets an IANA
284    /// timezone; `None` clears it so the operator falls back to the deployment
285    /// default. Validating that `name` is a real timezone is the caller's job.
286    pub async fn set_user_timezone(
287        &self,
288        user_id: i64,
289        timezone: Option<&str>,
290    ) -> Result<(), AuthError> {
291        store::set_user_timezone(&self.db, user_id, timezone).await
292    }
293
294    /// Persists an operator's own UI locale. `Some(tag)` sets a base language tag;
295    /// `None` clears it so the operator falls back to the deployment default.
296    /// Validating that `tag` is a supported locale is the caller's job.
297    pub async fn set_user_locale(
298        &self,
299        user_id: i64,
300        locale: Option<&str>,
301    ) -> Result<(), AuthError> {
302        store::set_user_locale(&self.db, user_id, locale).await
303    }
304
305    /// Loads a user's per-permission overrides (code to `1` allow or `-1` deny).
306    pub async fn user_permission_overrides(
307        &self,
308        user_id: i64,
309    ) -> Result<std::collections::HashMap<String, i64>, AuthError> {
310        store::load_user_permission_overrides(&self.db, user_id).await
311    }
312
313    /// Replaces a user's per-permission overrides. Callers pass only `1` and `-1`
314    /// entries; an inherited permission is represented by its absence.
315    pub async fn set_user_permissions(
316        &self,
317        user_id: i64,
318        overrides: &std::collections::HashMap<String, i64>,
319    ) -> Result<(), AuthError> {
320        store::set_user_permissions(&self.db, user_id, overrides).await
321    }
322
323    /// Appends an entry to the append-only audit log. Mutating admin paths call
324    /// this after a successful change, so actions that affect privileges or data
325    /// are recorded. Self-service preference changes are not audited.
326    pub async fn record_audit(&self, entry: AuditEntry<'_>) -> Result<(), AuthError> {
327        store::insert_audit_log(
328            &self.db,
329            entry.actor_user_id,
330            entry.actor_username,
331            entry.action,
332            entry.target_type,
333            entry.target_id,
334            entry.detail,
335        )
336        .await
337    }
338
339    /// The most recent audit entries, newest first, for the admin audit view.
340    pub async fn recent_audit(&self, limit: u64) -> Result<Vec<store::AuditRecord>, AuthError> {
341        store::recent_audit(&self.db, limit).await
342    }
343
344    /// Whether any backend operator exists yet. A fresh install with none is
345    /// routed to first-run setup instead of login.
346    pub async fn has_any_operator(&self) -> Result<bool, AuthError> {
347        store::any_user_exists(&self.db).await
348    }
349
350    /// Creates a superuser operator: hashes the password, inserts the user, and
351    /// records their timezone preference. The single account-creation path,
352    /// shared by the CLI and the first-run setup screen.
353    pub async fn create_superuser(&self, new: NewOperator<'_>) -> Result<i64, AuthError> {
354        let hash = password::hash_password(new.password)?;
355        let id = store::create_user(
356            &self.db,
357            new.username,
358            new.email,
359            new.first_name,
360            new.last_name,
361            &hash,
362            true,
363        )
364        .await?;
365        if new.timezone.is_some() {
366            store::set_user_timezone(&self.db, id, new.timezone).await?;
367        }
368        Ok(id)
369    }
370
371    async fn log(
372        &self,
373        user_id: Option<i64>,
374        username: &str,
375        event: AccessEvent,
376        ctx: &RequestContext,
377    ) -> Result<(), AuthError> {
378        store::insert_access_log(
379            &self.db,
380            user_id,
381            username,
382            event,
383            ctx.ip_address.as_deref(),
384            ctx.user_agent.as_deref(),
385        )
386        .await
387    }
388}
389
390/// Converts a small, in-range `std::time::Duration` to `chrono::Duration`.
391/// The auth policy durations are hours at most, well within range.
392fn chrono_from_std(d: Duration) -> chrono::Duration {
393    chrono::Duration::from_std(d).expect("auth policy duration out of range")
394}
395
396fn generate_token() -> String {
397    let mut bytes = [0u8; 32];
398    rand::rngs::OsRng.fill_bytes(&mut bytes);
399    to_hex(&bytes)
400}
401
402fn hash_token(token: &str) -> String {
403    let mut hasher = Sha256::new();
404    hasher.update(token.as_bytes());
405    to_hex(&hasher.finalize())
406}
407
408fn to_hex(bytes: &[u8]) -> String {
409    let mut out = String::with_capacity(bytes.len() * 2);
410    for byte in bytes {
411        let _ = write!(out, "{byte:02x}");
412    }
413    out
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    async fn seed_user(db: &Db, username: &str, password: &str, superuser: bool) -> i64 {
421        let hash = password::hash_password(password).unwrap();
422        store::create_user(
423            db,
424            username,
425            &format!("{username}@example.test"),
426            "Test",
427            Some("Operator"),
428            &hash,
429            superuser,
430        )
431        .await
432        .unwrap()
433    }
434
435    fn service(db: Db) -> AuthService {
436        AuthService::new(db, AuthConfig::default())
437    }
438
439    /// A fresh test database with this module's migrations applied through the
440    /// framework runner, on whichever backend the run targets (see
441    /// `laterite_core::testing`). Hold the returned guard for the test's lifetime.
442    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
443        laterite_core::testing::connect_test(&[crate::migrations()]).await
444    }
445
446    #[tokio::test]
447    async fn audit_entries_record_and_read_back_newest_first() {
448        let (pool, _guard) = test_db().await;
449        let actor = seed_user(&pool, "root", "hunter2", true).await;
450        let svc = service(pool);
451
452        svc.record_audit(AuditEntry {
453            actor_user_id: Some(actor),
454            actor_username: "root",
455            action: "backend.role.update",
456            target_type: Some("backend_role"),
457            target_id: Some("42"),
458            detail: Some(r#"{"added":["a.b"]}"#),
459        })
460        .await
461        .unwrap();
462        svc.record_audit(AuditEntry {
463            actor_user_id: Some(actor),
464            actor_username: "root",
465            action: "backend.plugin.disable",
466            target_type: Some("plugin"),
467            target_id: Some("acme.widgets"),
468            detail: None,
469        })
470        .await
471        .unwrap();
472
473        let entries = svc.recent_audit(10).await.unwrap();
474        assert_eq!(entries.len(), 2);
475        // Newest first.
476        assert_eq!(entries[0].action, "backend.plugin.disable");
477        assert_eq!(entries[0].target_id.as_deref(), Some("acme.widgets"));
478        assert_eq!(entries[0].detail, None);
479        assert_eq!(entries[1].action, "backend.role.update");
480        assert_eq!(entries[1].actor_username, "root");
481        assert_eq!(entries[1].actor_user_id, Some(actor));
482        assert_eq!(entries[1].detail.as_deref(), Some(r#"{"added":["a.b"]}"#));
483    }
484
485    #[tokio::test]
486    async fn authenticate_issues_a_verifiable_session() {
487        let (pool, _guard) = test_db().await;
488        seed_user(&pool, "root", "hunter2", true).await;
489        let svc = service(pool);
490
491        let session = svc
492            .authenticate("root", "hunter2", &RequestContext::default())
493            .await
494            .expect("login should succeed");
495        let identity = svc
496            .verify_session(&session.token)
497            .await
498            .expect("session should resolve");
499
500        assert_eq!(identity.user.username, "root");
501        assert_eq!(identity.user.full_name(), "Test Operator");
502        assert!(identity.allows("anything.superuser.can.do"));
503    }
504
505    #[tokio::test]
506    async fn session_data_blob_round_trips() {
507        let (pool, _guard) = test_db().await;
508        seed_user(&pool, "root", "hunter2", true).await;
509        let svc = service(pool);
510
511        let session = svc
512            .authenticate("root", "hunter2", &RequestContext::default())
513            .await
514            .unwrap();
515
516        // A fresh session has no blob.
517        let resolved = svc.resolve_session(&session.token).await.unwrap();
518        assert_eq!(resolved.data, None);
519        assert_eq!(resolved.identity.user.username, "root");
520
521        // The surface writes its opaque state; the next resolve reads it back.
522        svc.set_session_data(&session.token, r#"{"v":1,"csrf":"abc"}"#)
523            .await
524            .unwrap();
525        let resolved = svc.resolve_session(&session.token).await.unwrap();
526        assert_eq!(resolved.data.as_deref(), Some(r#"{"v":1,"csrf":"abc"}"#));
527    }
528
529    #[tokio::test]
530    async fn username_is_case_insensitive_across_backends() {
531        // The account is created lower-cased, and a differently-cased login
532        // resolves to it: this must hold identically on every backend (MySQL's
533        // collation is case-insensitive, Postgres and SQLite are not).
534        let (pool, _guard) = test_db().await;
535        let hash = password::hash_password("pw").unwrap();
536        store::create_user(
537            &pool,
538            "Root",
539            "Root@Example.test",
540            "Case",
541            None,
542            &hash,
543            true,
544        )
545        .await
546        .unwrap();
547        let svc = service(pool);
548        let session = svc
549            .authenticate("ROOT", "pw", &RequestContext::default())
550            .await
551            .expect("case-varied login should resolve to the same account");
552        let identity = svc.verify_session(&session.token).await.unwrap();
553        assert_eq!(identity.user.username, "root");
554        assert_eq!(identity.user.email, "root@example.test");
555    }
556
557    #[tokio::test]
558    async fn full_name_falls_back_to_first_name_when_last_is_absent() {
559        let (pool, _guard) = test_db().await;
560        let hash = password::hash_password("pw").unwrap();
561        store::create_user(
562            &pool,
563            "mono",
564            "mono@example.test",
565            "Prakash",
566            None,
567            &hash,
568            true,
569        )
570        .await
571        .unwrap();
572        let svc = service(pool);
573        let session = svc
574            .authenticate("mono", "pw", &RequestContext::default())
575            .await
576            .unwrap();
577        let identity = svc.verify_session(&session.token).await.unwrap();
578        assert_eq!(identity.user.full_name(), "Prakash");
579    }
580
581    #[tokio::test]
582    async fn operator_timezone_round_trips_and_clears() {
583        let (pool, _guard) = test_db().await;
584        let id = seed_user(&pool, "tz", "pw", true).await;
585
586        // A fresh operator has no preference and inherits the default.
587        let user = store::find_active_user_by_id(&pool, id)
588            .await
589            .unwrap()
590            .unwrap();
591        assert_eq!(user.timezone, None);
592
593        let svc = service(pool.clone());
594        svc.set_user_timezone(id, Some("Asia/Kolkata"))
595            .await
596            .unwrap();
597        let user = store::find_active_user_by_id(&pool, id)
598            .await
599            .unwrap()
600            .unwrap();
601        assert_eq!(user.timezone.as_deref(), Some("Asia/Kolkata"));
602
603        // Clearing it returns the operator to the default.
604        svc.set_user_timezone(id, None).await.unwrap();
605        let user = store::find_active_user_by_id(&pool, id)
606            .await
607            .unwrap()
608            .unwrap();
609        assert_eq!(user.timezone, None);
610    }
611
612    #[tokio::test]
613    async fn operator_locale_round_trips_and_clears() {
614        let (pool, _guard) = test_db().await;
615        let id = seed_user(&pool, "loc", "pw", true).await;
616
617        // A fresh operator has no preference and inherits the default.
618        let user = store::find_active_user_by_id(&pool, id)
619            .await
620            .unwrap()
621            .unwrap();
622        assert_eq!(user.locale, None);
623
624        let svc = service(pool.clone());
625        svc.set_user_locale(id, Some("kn")).await.unwrap();
626        let user = store::find_active_user_by_id(&pool, id)
627            .await
628            .unwrap()
629            .unwrap();
630        assert_eq!(user.locale.as_deref(), Some("kn"));
631
632        // Clearing it returns the operator to the default.
633        svc.set_user_locale(id, None).await.unwrap();
634        let user = store::find_active_user_by_id(&pool, id)
635            .await
636            .unwrap()
637            .unwrap();
638        assert_eq!(user.locale, None);
639    }
640
641    #[tokio::test]
642    async fn has_any_operator_flips_after_the_first_account() {
643        let (pool, _guard) = test_db().await;
644        let svc = service(pool);
645
646        // A fresh install has no operators, so setup (not login) applies.
647        assert!(!svc.has_any_operator().await.unwrap());
648
649        svc.create_superuser(NewOperator {
650            username: "first",
651            email: "first@example.test",
652            first_name: "First",
653            last_name: None,
654            password: "hunter2",
655            timezone: Some("Asia/Kolkata"),
656        })
657        .await
658        .unwrap();
659
660        assert!(svc.has_any_operator().await.unwrap());
661
662        // The account is a usable superuser with the onboarding timezone recorded.
663        let session = svc
664            .authenticate("first", "hunter2", &RequestContext::default())
665            .await
666            .unwrap();
667        let identity = svc.verify_session(&session.token).await.unwrap();
668        assert!(identity.allows("anything.a.superuser.can.do"));
669        assert_eq!(identity.user.timezone.as_deref(), Some("Asia/Kolkata"));
670    }
671
672    #[tokio::test]
673    async fn wrong_password_is_rejected() {
674        let (pool, _guard) = test_db().await;
675        seed_user(&pool, "root", "hunter2", false).await;
676        let svc = service(pool);
677
678        let err = svc
679            .authenticate("root", "wrong", &RequestContext::default())
680            .await
681            .unwrap_err();
682        assert!(matches!(err, AuthError::InvalidCredentials));
683    }
684
685    #[tokio::test]
686    async fn unknown_user_is_rejected_without_distinction() {
687        let (pool, _guard) = test_db().await;
688        let svc = service(pool);
689        let err = svc
690            .authenticate("ghost", "whatever", &RequestContext::default())
691            .await
692            .unwrap_err();
693        assert!(matches!(err, AuthError::InvalidCredentials));
694    }
695
696    #[tokio::test]
697    async fn lockout_trips_after_max_failures() {
698        let (pool, _guard) = test_db().await;
699        seed_user(&pool, "root", "hunter2", false).await;
700        let svc = AuthService::new(
701            pool,
702            AuthConfig {
703                max_failures: 3,
704                ..AuthConfig::default()
705            },
706        );
707        let ctx = RequestContext::default();
708
709        for _ in 0..3 {
710            let err = svc.authenticate("root", "bad", &ctx).await.unwrap_err();
711            assert!(matches!(err, AuthError::InvalidCredentials));
712        }
713        // The correct password is now refused: the account is locked out.
714        let err = svc.authenticate("root", "hunter2", &ctx).await.unwrap_err();
715        assert!(matches!(err, AuthError::TooManyAttempts));
716    }
717
718    #[tokio::test]
719    async fn permissions_come_from_assigned_roles() {
720        let (pool, _guard) = test_db().await;
721        let user_id = seed_user(&pool, "mod", "pw", false).await;
722        let role_id = store::create_role(
723            &pool,
724            "content_editor",
725            "Content Editor",
726            &["posts.*".to_string()],
727        )
728        .await
729        .unwrap();
730        store::assign_role(&pool, user_id, role_id).await.unwrap();
731
732        let svc = service(pool);
733        let session = svc
734            .authenticate("mod", "pw", &RequestContext::default())
735            .await
736            .unwrap();
737        let identity = svc.verify_session(&session.token).await.unwrap();
738
739        assert!(identity.allows("posts.approve"));
740        assert!(!identity.allows("users.edit"));
741        identity.require("posts.edit").unwrap();
742        assert!(identity.require("users.edit").is_err());
743    }
744
745    #[tokio::test]
746    async fn logout_invalidates_the_session() {
747        let (pool, _guard) = test_db().await;
748        seed_user(&pool, "root", "pw", true).await;
749        let svc = service(pool);
750
751        let session = svc
752            .authenticate("root", "pw", &RequestContext::default())
753            .await
754            .unwrap();
755        svc.verify_session(&session.token).await.unwrap();
756        svc.logout(&session.token).await.unwrap();
757
758        let err = svc.verify_session(&session.token).await.unwrap_err();
759        assert!(matches!(err, AuthError::SessionInvalid));
760    }
761
762    #[tokio::test]
763    async fn inactive_account_is_refused_after_correct_password() {
764        let (pool, _guard) = test_db().await;
765        let user_id = seed_user(&pool, "root", "pw", false).await;
766        // Deactivate through the query layer so the placeholder renders per backend.
767        let (sql, values) = laterite_core::query::build(
768            pool.backend,
769            sea_query::Query::update()
770                .table(crate::schema::BackendUsers::Table)
771                .value(crate::schema::BackendUsers::IsActive, false)
772                .and_where(sea_query::Expr::col(crate::schema::BackendUsers::Id).eq(user_id))
773                .to_owned(),
774        );
775        laterite_core::query::bind_values(sqlx::query(&sql), values)
776            .execute(&pool.pool)
777            .await
778            .unwrap();
779
780        let svc = service(pool);
781        let err = svc
782            .authenticate("root", "pw", &RequestContext::default())
783            .await
784            .unwrap_err();
785        assert!(matches!(err, AuthError::InactiveAccount));
786    }
787
788    #[tokio::test]
789    async fn reset_password_updates_the_hash() {
790        let (pool, _guard) = test_db().await;
791        seed_user(&pool, "root", "oldpw", true).await;
792
793        let new_hash = password::hash_password("newpw").unwrap();
794        let affected = store::update_password_by_username(&pool, "root", &new_hash)
795            .await
796            .unwrap();
797        assert_eq!(affected, 1);
798
799        let svc = service(pool);
800        let ctx = RequestContext::default();
801        assert!(matches!(
802            svc.authenticate("root", "oldpw", &ctx).await.unwrap_err(),
803            AuthError::InvalidCredentials
804        ));
805        svc.authenticate("root", "newpw", &ctx).await.unwrap();
806    }
807
808    #[tokio::test]
809    async fn reset_password_reports_unknown_user() {
810        let (pool, _guard) = test_db().await;
811        let hash = password::hash_password("x").unwrap();
812        let affected = store::update_password_by_username(&pool, "ghost", &hash)
813            .await
814            .unwrap();
815        assert_eq!(affected, 0);
816    }
817
818    #[tokio::test]
819    async fn list_users_returns_all_seeded() {
820        let (pool, _guard) = test_db().await;
821        seed_user(&pool, "alice", "pw", true).await;
822        seed_user(&pool, "bob", "pw", false).await;
823
824        let users = store::list_backend_users(&pool).await.unwrap();
825        assert_eq!(users.len(), 2);
826        assert!(users
827            .iter()
828            .any(|u| u.username == "alice" && u.is_superuser));
829        assert!(users.iter().any(|u| u.username == "bob" && !u.is_superuser));
830    }
831
832    #[tokio::test]
833    async fn unlock_clears_the_lockout() {
834        let (pool, _guard) = test_db().await;
835        seed_user(&pool, "root", "pw", false).await;
836        let svc = AuthService::new(
837            pool.clone(),
838            AuthConfig {
839                max_failures: 3,
840                ..AuthConfig::default()
841            },
842        );
843        let ctx = RequestContext::default();
844
845        for _ in 0..3 {
846            let _ = svc.authenticate("root", "bad", &ctx).await;
847        }
848        assert!(matches!(
849            svc.authenticate("root", "pw", &ctx).await.unwrap_err(),
850            AuthError::TooManyAttempts
851        ));
852
853        let cleared = store::clear_failed_attempts(&pool, "root").await.unwrap();
854        assert!(cleared >= 3);
855        svc.authenticate("root", "pw", &ctx).await.unwrap();
856    }
857}