fabrica

hanna/fabrica

feat(store): web session queries

0a2f109 · hanna committed on 2026-07-25

Add sessions.rs: create a session (id = SHA-256 of the cookie value),
resolve the user behind a live session (joining users, filtering expired
sessions and disabled accounts), touch last_seen at most once a minute,
delete on logout, and reap expired rows. USER_COLUMNS/map_user are now
crate-visible so the join reuses them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +187 −2UnifiedSplit
crates/store/src/lib.rs +2 −0
@@ -29,6 +29,7 @@ mod groups;
29mod invites;29mod invites;
30mod keys;30mod keys;
31mod repos;31mod repos;
32mod sessions;
32mod tokens;33mod tokens;
33mod users;34mod users;
3435
@@ -43,6 +44,7 @@ use sqlx::migrate::Migrator;
43pub use crate::invites::NewInvite;44pub use crate::invites::NewInvite;
44pub use crate::keys::NewKey;45pub use crate::keys::NewKey;
45pub use crate::repos::NewRepo;46pub use crate::repos::NewRepo;
47pub use crate::sessions::NewSession;
46pub use crate::tokens::NewToken;48pub use crate::tokens::NewToken;
47pub use crate::users::NewUser;49pub use crate::users::NewUser;
4850
crates/store/src/sessions.rs +127 −0
@@ -0,0 +1,127 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Web sessions.
6//!
7//! The `sessions.id` primary key is the SHA-256 of the cookie value (never the
8//! cookie itself), so a database leak does not yield usable sessions. Lookup
9//! filters on `expires_at` so an expired row is treated as absent.
10
11use model::User;
12use sqlx::Row;
13
14use crate::users::USER_COLUMNS;
15use crate::{Store, StoreError, now_ms};
16
17/// Fields needed to create a session row.
18#[derive(Debug, Clone)]
19pub struct NewSession {
20 /// The session id: SHA-256 (hex) of the cookie value.
21 pub id: String,
22 /// The authenticated user's id.
23 pub user_id: String,
24 /// The client user-agent, if known.
25 pub user_agent: Option<String>,
26 /// The client IP, if known.
27 pub ip: Option<String>,
28 /// Absolute expiry, epoch ms.
29 pub expires_at: i64,
30}
31
32impl Store {
33 /// Create a session row.
34 ///
35 /// # Errors
36 ///
37 /// Returns [`StoreError::Query`] on a database failure.
38 pub async fn create_session(&self, new: NewSession) -> Result<(), StoreError> {
39 let now = now_ms();
40 sqlx::query(
41 "INSERT INTO sessions (id, user_id, user_agent, ip, created_at, last_seen_at, expires_at) \
42 VALUES ($1, $2, $3, $4, $5, $5, $6)",
43 )
44 .bind(new.id)
45 .bind(new.user_id)
46 .bind(new.user_agent)
47 .bind(new.ip)
48 .bind(now)
49 .bind(new.expires_at)
50 .execute(&self.pool)
51 .await?;
52 Ok(())
53 }
54
55 /// Resolve the user behind a live (non-expired) session, or `None`.
56 ///
57 /// A disabled user resolves to `None` — a session must not outlive the
58 /// account's ability to log in.
59 ///
60 /// # Errors
61 ///
62 /// Returns [`StoreError::Query`] on a database failure.
63 pub async fn user_for_session(&self, session_id: &str) -> Result<Option<User>, StoreError> {
64 let sql = format!(
65 "SELECT {cols} FROM users u JOIN sessions s ON s.user_id = u.id \
66 WHERE s.id = $1 AND s.expires_at > $2 AND u.disabled_at IS NULL",
67 cols = USER_COLUMNS
68 .split(", ")
69 .map(|c| format!("u.{c}"))
70 .collect::<Vec<_>>()
71 .join(", "),
72 );
73 let row = sqlx::query(sqlx::AssertSqlSafe(sql))
74 .bind(session_id)
75 .bind(now_ms())
76 .fetch_optional(&self.pool)
77 .await?;
78 Ok(row.as_ref().map(|r| self.map_user(r)).transpose()?)
79 }
80
81 /// Update a session's `last_seen_at`, at most once a minute.
82 ///
83 /// # Errors
84 ///
85 /// Returns [`StoreError::Query`] on a database failure.
86 pub async fn touch_session(&self, session_id: &str) -> Result<(), StoreError> {
87 let now = now_ms();
88 sqlx::query(
89 "UPDATE sessions SET last_seen_at = $1 \
90 WHERE id = $2 AND last_seen_at < $3",
91 )
92 .bind(now)
93 .bind(session_id)
94 .bind(now - 60_000)
95 .execute(&self.pool)
96 .await?;
97 Ok(())
98 }
99
100 /// Delete a session (logout). Returns `true` if a row was removed.
101 ///
102 /// # Errors
103 ///
104 /// Returns [`StoreError::Query`] on a database failure.
105 pub async fn delete_session(&self, session_id: &str) -> Result<bool, StoreError> {
106 let deleted = sqlx::query("DELETE FROM sessions WHERE id = $1")
107 .bind(session_id)
108 .execute(&self.pool)
109 .await?
110 .rows_affected();
111 Ok(deleted > 0)
112 }
113
114 /// Delete every expired session. Returns how many rows were removed.
115 ///
116 /// # Errors
117 ///
118 /// Returns [`StoreError::Query`] on a database failure.
119 pub async fn delete_expired_sessions(&self) -> Result<u64, StoreError> {
120 let deleted = sqlx::query("DELETE FROM sessions WHERE expires_at <= $1")
121 .bind(now_ms())
122 .execute(&self.pool)
123 .await?
124 .rows_affected();
125 Ok(deleted)
126 }
127}
crates/store/src/tests.rs +56 −0
@@ -733,6 +733,62 @@ async fn rename_and_delete_repo() {
733}733}
734734
735#[tokio::test]735#[tokio::test]
736async fn sessions_resolve_the_user_and_respect_expiry() {
737 use super::NewSession;
738
739 for fx in sqlite_fixtures().await {
740 let user = fx
741 .store
742 .create_user(sample_user("s", "s@example.com"))
743 .await
744 .unwrap();
745
746 fx.store
747 .create_session(NewSession {
748 id: "sess-live".to_string(),
749 user_id: user.id.clone(),
750 user_agent: Some("curl".to_string()),
751 ip: None,
752 expires_at: now_plus_a_day(),
753 })
754 .await
755 .unwrap();
756 let resolved = fx.store.user_for_session("sess-live").await.unwrap();
757 assert_eq!(resolved.map(|u| u.id), Some(user.id.clone()));
758
759 // An expired session resolves to nobody and is reaped.
760 fx.store
761 .create_session(NewSession {
762 id: "sess-dead".to_string(),
763 user_id: user.id.clone(),
764 user_agent: None,
765 ip: None,
766 expires_at: 1,
767 })
768 .await
769 .unwrap();
770 assert!(
771 fx.store
772 .user_for_session("sess-dead")
773 .await
774 .unwrap()
775 .is_none()
776 );
777 assert_eq!(fx.store.delete_expired_sessions().await.unwrap(), 1);
778
779 // Logout deletes the live session.
780 assert!(fx.store.delete_session("sess-live").await.unwrap());
781 assert!(
782 fx.store
783 .user_for_session("sess-live")
784 .await
785 .unwrap()
786 .is_none()
787 );
788 }
789}
790
791#[tokio::test]
736async fn invites_round_trip_and_are_single_use() {792async fn invites_round_trip_and_are_single_use() {
737 use super::NewInvite;793 use super::NewInvite;
738794
crates/store/src/users.rs +2 −2
@@ -12,7 +12,7 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
13/// The columns of `users` in a fixed order, shared by every `SELECT` so the13/// The columns of `users` in a fixed order, shared by every `SELECT` so the
14/// row-mapping in [`Store::map_user`] can rely on names.14/// row-mapping in [`Store::map_user`] can rely on names.
15const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \15pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \
16 must_change_password, is_admin, disabled_at, created_at, updated_at";16 must_change_password, is_admin, disabled_at, created_at, updated_at";
1717
18/// Fields needed to create a user. The server fills in the id, timestamps, and18/// Fields needed to create a user. The server fills in the id, timestamps, and
@@ -221,7 +221,7 @@ impl Store {
221 }221 }
222222
223 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].223 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].
224 fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {224 pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {
225 Ok(User {225 Ok(User {
226 id: row.try_get("id")?,226 id: row.try_get("id")?,
227 username: row.try_get("username")?,227 username: row.try_get("username")?,