steid

@jamesgill /

feat: session storage and actor resolution

sessions table, SessionRepository port with in-memory and sqlite
implementations, and the use cases that sit on top. 10 tests.

Topcoat issues the raw token to the client and hands us only its SHA-256
hash, so the table holds nothing anyone could present.

resolve_actor is the authorization entry point -- it decides whether a
request is anonymous or authenticated -- so it lives in a use case rather
than a page, per architecture.md. It never fails open and never errors on a
bad session: an absent, unknown, or expired token all resolve to
Actor::Anonymous.

Expiry is checked on read rather than trusted to the sweep, so a session
that outlives a cleanup run still stops working on time. Both the strictly-
past and exact-boundary cases are tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparentbf4b351Browse files54a0eaf78337787d5d35c9b1a281a194a08add1b

10 files changed+457 −7

migrations/20260804093033_create_sessions.sql+12 −0View file
@@ -0,0 +1,12 @@
1+-- Session records. Topcoat issues the raw token to the client and hands us only its
2+-- SHA-256 hash, so a dumped database contains nothing anyone could present.
3+--
4+-- expires_at is unix seconds. Rows are not self-cleaning: expiry is enforced on read
5+-- and swept periodically.
6+create table sessions (
7+ token_hash text primary key,
8+ user_id text not null references users (id) on delete cascade,
9+ expires_at integer not null
10+);
11+
12+create index sessions_expires_at on sessions (expires_at);
src/application/mod.rs+2 −0View file
@@ -8,8 +8,10 @@ pub mod config;
88 pub mod error;
99 pub mod login;
1010 pub mod port;
11+pub mod session;
1112
1213 pub use claim::{OwnerSpec, claim_instance, is_claimed};
1314 pub use config::AppConfig;
1415 pub use error::{Error, Result};
1516 pub use login::login;
17+pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
src/application/session.rs+191 −0View file
@@ -0,0 +1,191 @@
1+use std::time::{Duration, SystemTime};
2+
3+use crate::domain::{
4+ Actor, Session, SessionTokenHash, UserId,
5+ repository::{RepositoryResult, SessionRepository},
6+};
7+
8+use super::error::Result;
9+
10+/// How long a new session lasts.
11+pub const SESSION_LIFETIME: Duration = Duration::from_secs(60 * 60 * 24 * 30);
12+
13+/// Records a session Topcoat has just issued.
14+pub async fn record_session(
15+ token_hash: SessionTokenHash,
16+ user_id: UserId,
17+ expires_at: SystemTime,
18+ sessions: &impl SessionRepository,
19+) -> Result<()> {
20+ let session = Session::new(token_hash, user_id, expires_at);
21+ sessions.save(&session).await?;
22+ Ok(())
23+}
24+
25+/// Resolves the actor behind a presented session token.
26+///
27+/// This is the authorization entry point: it is what decides whether a request is
28+/// anonymous or authenticated, so it lives in a use case rather than a page. Anything
29+/// unrecognised or expired resolves to [`Actor::Anonymous`] — it never fails open, and
30+/// it never errors merely because a session is bad.
31+pub async fn resolve_actor(
32+ token_hash: Option<&SessionTokenHash>,
33+ now: SystemTime,
34+ sessions: &impl SessionRepository,
35+) -> Result<Actor> {
36+ let Some(token_hash) = token_hash else {
37+ return Ok(Actor::Anonymous);
38+ };
39+
40+ let Some(session) = sessions.find(token_hash).await? else {
41+ return Ok(Actor::Anonymous);
42+ };
43+
44+ if session.is_expired_at(now) {
45+ // Expiry is enforced here, not left to the sweep, so a session that outlives
46+ // a cleanup run still stops working on time.
47+ return Ok(Actor::Anonymous);
48+ }
49+
50+ Ok(Actor::User(session.user_id))
51+}
52+
53+/// Forgets a session. Called on logout, alongside Topcoat discarding the cookie.
54+pub async fn end_session(
55+ token_hash: &SessionTokenHash,
56+ sessions: &impl SessionRepository,
57+) -> Result<()> {
58+ sessions.delete(token_hash).await?;
59+ Ok(())
60+}
61+
62+/// Removes expired sessions. Safe to call at any time.
63+pub async fn sweep_expired(
64+ now: SystemTime,
65+ sessions: &impl SessionRepository,
66+) -> RepositoryResult<u64> {
67+ sessions.delete_expired(now).await
68+}
69+
70+#[cfg(test)]
71+mod tests {
72+ use super::*;
73+ use crate::infrastructure::repository::InMemorySessionRepo;
74+
75+ fn at(seconds: u64) -> SystemTime {
76+ SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
77+ }
78+
79+ fn hash(value: &str) -> SessionTokenHash {
80+ SessionTokenHash::from_trusted(value)
81+ }
82+
83+ async fn repo_with_session(expires_at: SystemTime) -> (InMemorySessionRepo, UserId) {
84+ let sessions = InMemorySessionRepo::new();
85+ let user_id = UserId::generate();
86+ record_session(hash("live"), user_id.clone(), expires_at, &sessions)
87+ .await
88+ .expect("record");
89+ (sessions, user_id)
90+ }
91+
92+ #[tokio::test]
93+ async fn no_token_resolves_to_anonymous() {
94+ let sessions = InMemorySessionRepo::new();
95+
96+ let actor = resolve_actor(None, at(100), &sessions)
97+ .await
98+ .expect("resolve");
99+
100+ assert_eq!(actor, Actor::Anonymous);
101+ }
102+
103+ #[tokio::test]
104+ async fn an_unknown_token_resolves_to_anonymous() {
105+ let sessions = InMemorySessionRepo::new();
106+
107+ let actor = resolve_actor(Some(&hash("nope")), at(100), &sessions)
108+ .await
109+ .expect("resolve");
110+
111+ assert_eq!(
112+ actor,
113+ Actor::Anonymous,
114+ "an unrecognised token must not error -- it must simply not authenticate"
115+ );
116+ }
117+
118+ #[tokio::test]
119+ async fn a_live_session_resolves_to_its_user() {
120+ let (sessions, user_id) = repo_with_session(at(1_000)).await;
121+
122+ let actor = resolve_actor(Some(&hash("live")), at(500), &sessions)
123+ .await
124+ .expect("resolve");
125+
126+ assert_eq!(actor, Actor::User(user_id));
127+ }
128+
129+ #[tokio::test]
130+ async fn an_expired_session_resolves_to_anonymous_even_before_a_sweep() {
131+ let (sessions, _) = repo_with_session(at(1_000)).await;
132+
133+ let actor = resolve_actor(Some(&hash("live")), at(1_001), &sessions)
134+ .await
135+ .expect("resolve");
136+
137+ assert_eq!(
138+ actor,
139+ Actor::Anonymous,
140+ "expiry must be enforced on read, not left to the cleanup job"
141+ );
142+ }
143+
144+ #[tokio::test]
145+ async fn expiry_is_enforced_at_the_exact_boundary() {
146+ let (sessions, _) = repo_with_session(at(1_000)).await;
147+
148+ let actor = resolve_actor(Some(&hash("live")), at(1_000), &sessions)
149+ .await
150+ .expect("resolve");
151+
152+ assert_eq!(actor, Actor::Anonymous);
153+ }
154+
155+ #[tokio::test]
156+ async fn ending_a_session_stops_it_resolving() {
157+ let (sessions, _) = repo_with_session(at(1_000)).await;
158+
159+ end_session(&hash("live"), &sessions)
160+ .await
161+ .expect("end session");
162+
163+ let actor = resolve_actor(Some(&hash("live")), at(500), &sessions)
164+ .await
165+ .expect("resolve");
166+
167+ assert_eq!(actor, Actor::Anonymous);
168+ }
169+
170+ #[tokio::test]
171+ async fn sweeping_removes_only_expired_sessions() {
172+ let sessions = InMemorySessionRepo::new();
173+ record_session(hash("expired"), UserId::generate(), at(100), &sessions)
174+ .await
175+ .expect("record");
176+ let live_user = UserId::generate();
177+ record_session(hash("live"), live_user.clone(), at(10_000), &sessions)
178+ .await
179+ .expect("record");
180+
181+ let removed = sweep_expired(at(1_000), &sessions).await.expect("sweep");
182+
183+ assert_eq!(removed, 1);
184+ assert_eq!(
185+ resolve_actor(Some(&hash("live")), at(1_000), &sessions)
186+ .await
187+ .expect("resolve"),
188+ Actor::User(live_user)
189+ );
190+ }
191+}
src/domain/mod.rs+2 −0View file
@@ -11,6 +11,7 @@ pub mod membership;
1111 pub mod org;
1212 pub mod password;
1313 pub mod repository;
14+pub mod session;
1415 pub mod setup_token;
1516 pub mod user;
1617
@@ -21,5 +22,6 @@ pub use id::{MembershipId, OrgId, UserId};
2122 pub use membership::{Membership, Role};
2223 pub use org::{OrgName, Organization};
2324 pub use password::PasswordHash;
25+pub use session::{Session, SessionTokenHash};
2426 pub use setup_token::SetupToken;
2527 pub use user::User;
src/domain/repository/mod.rs+2 −0View file
@@ -6,10 +6,12 @@
66
77 pub mod membership_repo;
88 pub mod org_repo;
9+pub mod session_repo;
910 pub mod user_repo;
1011
1112 pub use membership_repo::MembershipRepository;
1213 pub use org_repo::OrgRepository;
14+pub use session_repo::SessionRepository;
1315 pub use user_repo::UserRepository;
1416
1517 /// What a repository can fail with.
src/domain/repository/session_repo.rs+28 −0View file
@@ -0,0 +1,28 @@
1+use std::time::SystemTime;
2+
3+use super::RepositoryResult;
4+use crate::domain::session::{Session, SessionTokenHash};
5+
6+/// Persistence for [`Session`].
7+pub trait SessionRepository: Send + Sync {
8+ /// Looks a session up by token hash.
9+ ///
10+ /// Returns whatever is stored, expired or not — expiry is the caller's decision so
11+ /// that a stale row and a missing row stay distinguishable here.
12+ fn find(
13+ &self,
14+ token_hash: &SessionTokenHash,
15+ ) -> impl Future<Output = RepositoryResult<Option<Session>>> + Send;
16+
17+ fn save(&self, session: &Session) -> impl Future<Output = RepositoryResult<()>> + Send;
18+
19+ /// Removes a single session. Used on logout.
20+ fn delete(
21+ &self,
22+ token_hash: &SessionTokenHash,
23+ ) -> impl Future<Output = RepositoryResult<()>> + Send;
24+
25+ /// Removes every session that expired before `now`, returning how many went.
26+ fn delete_expired(&self, now: SystemTime)
27+ -> impl Future<Output = RepositoryResult<u64>> + Send;
28+}
src/domain/session.rs+89 −0View file
@@ -0,0 +1,89 @@
1+use std::time::SystemTime;
2+
3+use super::UserId;
4+
5+/// The hash of a session token, as the application records it.
6+///
7+/// Opaque and hex-encoded. The raw token exists only on the client — Topcoat issues it
8+/// and hands us the hash — so a dumped `sessions` table contains nothing presentable.
9+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10+pub struct SessionTokenHash(String);
11+
12+impl SessionTokenHash {
13+ /// Wraps an already-computed hash.
14+ pub fn from_trusted(value: impl Into<String>) -> Self {
15+ Self(value.into())
16+ }
17+
18+ pub fn as_str(&self) -> &str {
19+ &self.0
20+ }
21+}
22+
23+/// An authenticated session.
24+#[derive(Debug, Clone, PartialEq, Eq)]
25+pub struct Session {
26+ pub token_hash: SessionTokenHash,
27+ pub user_id: UserId,
28+ pub expires_at: SystemTime,
29+}
30+
31+impl Session {
32+ pub fn new(token_hash: SessionTokenHash, user_id: UserId, expires_at: SystemTime) -> Self {
33+ Self {
34+ token_hash,
35+ user_id,
36+ expires_at,
37+ }
38+ }
39+
40+ /// Whether the session has passed its expiry.
41+ ///
42+ /// Checked on every read rather than trusted to a cleanup job, so a session that
43+ /// outlives the sweep still stops working on time.
44+ pub fn is_expired_at(&self, now: SystemTime) -> bool {
45+ now >= self.expires_at
46+ }
47+}
48+
49+#[cfg(test)]
50+mod tests {
51+ use std::time::Duration;
52+
53+ use super::*;
54+
55+ fn session(expires_at: SystemTime) -> Session {
56+ Session::new(
57+ SessionTokenHash::from_trusted("abc123"),
58+ UserId::generate(),
59+ expires_at,
60+ )
61+ }
62+
63+ #[test]
64+ fn a_future_expiry_is_not_expired() {
65+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
66+ let session = session(now + Duration::from_secs(60));
67+
68+ assert!(!session.is_expired_at(now));
69+ }
70+
71+ #[test]
72+ fn a_past_expiry_is_expired() {
73+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
74+ let session = session(now - Duration::from_secs(1));
75+
76+ assert!(session.is_expired_at(now));
77+ }
78+
79+ #[test]
80+ fn expiry_is_inclusive_at_the_boundary() {
81+ let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
82+ let session = session(now);
83+
84+ assert!(
85+ session.is_expired_at(now),
86+ "a session should not survive the instant it expires"
87+ );
88+ }
89+}
src/infrastructure/repository/in_memory.rs+42 −2View file
@@ -6,11 +6,14 @@
66 use std::{
77 collections::HashMap,
88 sync::{Arc, Mutex},
9+ time::SystemTime,
910 };
1011
1112 use crate::domain::{
12 Email, Membership, OrgId, OrgName, Organization, User, UserId,
13 repository::{MembershipRepository, OrgRepository, RepositoryResult, UserRepository},
13+ Email, Membership, OrgId, OrgName, Organization, Session, SessionTokenHash, User, UserId,
14+ repository::{
15+ MembershipRepository, OrgRepository, RepositoryResult, SessionRepository, UserRepository,
16+ },
1417 };
1518
1619 /// Shared, cloneable storage. Cloning shares the same underlying map, so a repository
@@ -293,3 +296,40 @@ mod tests {
293296 assert!(found.iter().all(|m| m.user_id == user_id));
294297 }
295298 }
299+
300+#[derive(Debug, Default, Clone)]
301+pub struct InMemorySessionRepo {
302+ sessions: Arc<Mutex<HashMap<String, Session>>>,
303+}
304+
305+impl InMemorySessionRepo {
306+ pub fn new() -> Self {
307+ Self::default()
308+ }
309+}
310+
311+impl SessionRepository for InMemorySessionRepo {
312+ async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
313+ let sessions = self.sessions.lock().expect("lock poisoned");
314+ Ok(sessions.get(token_hash.as_str()).cloned())
315+ }
316+
317+ async fn save(&self, session: &Session) -> RepositoryResult<()> {
318+ let mut sessions = self.sessions.lock().expect("lock poisoned");
319+ sessions.insert(session.token_hash.as_str().to_owned(), session.clone());
320+ Ok(())
321+ }
322+
323+ async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
324+ let mut sessions = self.sessions.lock().expect("lock poisoned");
325+ sessions.remove(token_hash.as_str());
326+ Ok(())
327+ }
328+
329+ async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
330+ let mut sessions = self.sessions.lock().expect("lock poisoned");
331+ let before = sessions.len();
332+ sessions.retain(|_, session| !session.is_expired_at(now));
333+ Ok((before - sessions.len()) as u64)
334+ }
335+}
src/infrastructure/repository/mod.rs+4 −2View file
@@ -1,5 +1,7 @@
11 pub mod in_memory;
22 pub mod sqlite;
33
4pub use in_memory::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo};
5pub use sqlite::{SqliteMembershipRepo, SqliteOrgRepo, SqliteUserRepo};
4+pub use in_memory::{
5+ InMemoryMembershipRepo, InMemoryOrgRepo, InMemorySessionRepo, InMemoryUserRepo,
6+};
7+pub use sqlite::{SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo};
src/infrastructure/repository/sqlite.rs+85 −3View file
@@ -3,13 +3,16 @@
33 //! Rows are reassembled with `from_trusted`: they were validated on the way in, and
44 //! re-validating them would make a tightened rule turn old rows unreadable.
55
6+use std::time::{Duration, SystemTime};
7+
68 use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
79
810 use crate::domain::{
9 Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, Role, User,
10 UserId,
11+ Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, Role, Session,
12+ SessionTokenHash, User, UserId,
1113 repository::{
12 MembershipRepository, OrgRepository, RepositoryError, RepositoryResult, UserRepository,
14+ MembershipRepository, OrgRepository, RepositoryError, RepositoryResult, SessionRepository,
15+ UserRepository,
1316 },
1417 };
1518
@@ -471,3 +474,82 @@ mod tests {
471474 );
472475 }
473476 }
477+
478+#[derive(Debug, Clone)]
479+pub struct SqliteSessionRepo {
480+ pool: SqlitePool,
481+}
482+
483+impl SqliteSessionRepo {
484+ pub fn new(pool: SqlitePool) -> Self {
485+ Self { pool }
486+ }
487+}
488+
489+/// Unix seconds. Times before the epoch cannot occur here — sessions always expire in
490+/// the future — so saturating at 0 is safe rather than lossy.
491+fn to_unix(time: SystemTime) -> i64 {
492+ time.duration_since(SystemTime::UNIX_EPOCH)
493+ .map(|d| d.as_secs() as i64)
494+ .unwrap_or(0)
495+}
496+
497+fn from_unix(seconds: i64) -> SystemTime {
498+ SystemTime::UNIX_EPOCH + Duration::from_secs(seconds.max(0) as u64)
499+}
500+
501+impl SessionRepository for SqliteSessionRepo {
502+ async fn find(&self, token_hash: &SessionTokenHash) -> RepositoryResult<Option<Session>> {
503+ let row = sqlx::query("select * from sessions where token_hash = ?")
504+ .bind(token_hash.as_str())
505+ .fetch_optional(&self.pool)
506+ .await
507+ .map_err(backend)?;
508+
509+ Ok(row.map(|row| {
510+ Session::new(
511+ SessionTokenHash::from_trusted(row.get::<String, _>("token_hash")),
512+ UserId::from_trusted(row.get::<String, _>("user_id")),
513+ from_unix(row.get::<i64, _>("expires_at")),
514+ )
515+ }))
516+ }
517+
518+ async fn save(&self, session: &Session) -> RepositoryResult<()> {
519+ sqlx::query(
520+ "insert into sessions (token_hash, user_id, expires_at)
521+ values (?, ?, ?)
522+ on conflict (token_hash) do update set
523+ user_id = excluded.user_id,
524+ expires_at = excluded.expires_at",
525+ )
526+ .bind(session.token_hash.as_str())
527+ .bind(session.user_id.as_str())
528+ .bind(to_unix(session.expires_at))
529+ .execute(&self.pool)
530+ .await
531+ .map_err(backend)?;
532+
533+ Ok(())
534+ }
535+
536+ async fn delete(&self, token_hash: &SessionTokenHash) -> RepositoryResult<()> {
537+ sqlx::query("delete from sessions where token_hash = ?")
538+ .bind(token_hash.as_str())
539+ .execute(&self.pool)
540+ .await
541+ .map_err(backend)?;
542+
543+ Ok(())
544+ }
545+
546+ async fn delete_expired(&self, now: SystemTime) -> RepositoryResult<u64> {
547+ let result = sqlx::query("delete from sessions where expires_at <= ?")
548+ .bind(to_unix(now))
549+ .execute(&self.pool)
550+ .await
551+ .map_err(backend)?;
552+
553+ Ok(result.rows_affected())
554+ }
555+}