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;
2929 mod invites;
3030 mod keys;
3131 mod repos;
32+mod sessions;
3233 mod tokens;
3334 mod users;
3435
@@ -43,6 +44,7 @@ use sqlx::migrate::Migrator;
4344 pub use crate::invites::NewInvite;
4445 pub use crate::keys::NewKey;
4546 pub use crate::repos::NewRepo;
47+pub use crate::sessions::NewSession;
4648 pub use crate::tokens::NewToken;
4749 pub 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+
11+use model::User;
12+use sqlx::Row;
13+
14+use crate::users::USER_COLUMNS;
15+use crate::{Store, StoreError, now_ms};
16+
17+/// Fields needed to create a session row.
18+#[derive(Debug, Clone)]
19+pub 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+
32+impl 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() {
733733 }
734734
735735 #[tokio::test]
736+async 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]
736792 async fn invites_round_trip_and_are_single_use() {
737793 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
1313 /// The columns of `users` in a fixed order, shared by every `SELECT` so the
1414 /// row-mapping in [`Store::map_user`] can rely on names.
15-const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \
15+pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \
1616 must_change_password, is_admin, disabled_at, created_at, updated_at";
1717
1818 /// Fields needed to create a user. The server fills in the id, timestamps, and
@@ -221,7 +221,7 @@ impl Store {
221221 }
222222
223223 /// 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> {
225225 Ok(User {
226226 id: row.try_get("id")?,
227227 username: row.try_get("username")?,