Skip to main content

tablo_core/auth/
session.rs

1//! Server-side sessions: the [`AuthSession`] rows, their lookup, revocation
2//! and sweep.
3
4use std::{sync::Arc, time::Duration};
5
6use jiff::Timestamp;
7use topcoat::{
8    context::Cx,
9    session::{self, TokenHash},
10};
11use uuid::Uuid;
12
13use super::{DynAuthenticator, PanelUser, SignedIn, infrastructure_failure};
14use crate::panel::state::{PanelState, current};
15
16/// How long a session stays valid: seven days, fixed (ADR-0013).
17pub const SESSION_LIFETIME: Duration = Duration::from_hours(24 * 7);
18
19/// Shipped server-side session record: the SHA-256 hash of the client token,
20/// the user it authenticates, the panel that signed the user in, and its
21/// expiry. The raw token is never stored.
22#[derive(Debug, Clone, toasty::Model)]
23pub struct AuthSession {
24    /// Hex-encoded SHA-256 of the session token.
25    #[key]
26    pub token_hash: String,
27    /// [`PanelUser::user_id`] of the authenticated user.
28    #[index]
29    pub user_id: String,
30    /// The prefix of the panel that signed the user in (e.g. `/admin`): the
31    /// only panel the session authenticates.
32    pub panel: String,
33    /// The tenant the user selected; `None` acts for the first.
34    pub tenant: Option<Uuid>,
35    /// Indexed for the login sweep's range scan on this column.
36    #[index]
37    pub expires_at: Timestamp,
38    pub created_at: Timestamp,
39}
40
41pub(super) fn token_key(hash: &TokenHash) -> String {
42    use std::fmt::Write as _;
43
44    let mut key = String::with_capacity(64);
45    for byte in hash.iter() {
46        write!(key, "{byte:02x}").expect("writing to a String cannot fail");
47    }
48    key
49}
50
51pub(super) async fn record(
52    cx: &Cx,
53    session: &session::Session,
54    user: &dyn PanelUser,
55    panel: &PanelState,
56) -> topcoat::Result<()> {
57    let mut db = crate::db::db(cx);
58    toasty::create!(AuthSession {
59        token_hash: token_key(&session.token_hash),
60        user_id: user.user_id(),
61        panel: panel.prefix.clone(),
62        tenant: None,
63        expires_at: Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?,
64        created_at: Timestamp::now(),
65    })
66    .exec(&mut db)
67    .await
68    .map_err(infrastructure_failure)?;
69    Ok(())
70}
71
72pub(super) async fn select_tenant(cx: &Cx, tenant: Uuid) -> topcoat::Result<()> {
73    let Some(hash) = session::token_hash(cx).await? else {
74        return Err(topcoat::router::error::forbidden().into());
75    };
76    let mut db = crate::db::db(cx);
77    AuthSession::filter(AuthSession::fields().token_hash().eq(token_key(&hash)))
78        .update()
79        .tenant(Some(tenant))
80        .exec(&mut db)
81        .await
82        .map_err(infrastructure_failure)?;
83    Ok(())
84}
85
86pub(super) async fn delete_session(cx: &Cx, hash: &TokenHash) -> topcoat::Result<()> {
87    delete_session_row(cx, &token_key(hash)).await
88}
89
90async fn delete_session_row(cx: &Cx, key: &str) -> topcoat::Result<()> {
91    let mut db = crate::db::db(cx);
92    AuthSession::filter(AuthSession::fields().token_hash().eq(key.to_string()))
93        .delete()
94        .exec(&mut db)
95        .await
96        .map_err(infrastructure_failure)?;
97    Ok(())
98}
99
100/// Revokes every live session of `user_id` for the current panel, or on every
101/// panel outside any panel; call it whenever a credential changes.
102///
103/// # Errors
104///
105/// The opaque sign-in outage when the database cannot answer.
106pub async fn revoke_sessions_for_user(cx: &Cx, user_id: &str) -> topcoat::Result<()> {
107    let mut db = crate::db::db(cx);
108    let user = AuthSession::fields().user_id().eq(user_id.to_string());
109    let sessions = match current(cx) {
110        Some(panel) => {
111            AuthSession::filter(user.and(AuthSession::fields().panel().eq(panel.prefix.clone())))
112        }
113        None => AuthSession::filter(user),
114    };
115    sessions
116        .delete()
117        .exec(&mut db)
118        .await
119        .map_err(infrastructure_failure)?;
120    Ok(())
121}
122
123/// How many expired session rows one sweep drops: the bound that keeps one
124/// login from deleting an unbounded number of rows.
125pub(super) const SESSION_SWEEP_BATCH: usize = 500;
126
127/// Drops up to [`SESSION_SWEEP_BATCH`] expired session rows on successful login.
128pub(super) async fn sweep_expired_sessions(cx: &Cx) -> topcoat::Result<()> {
129    let now = Timestamp::now();
130    let mut db = crate::db::db(cx);
131    let expired: Vec<String> = AuthSession::filter(AuthSession::fields().expires_at().le(now))
132        .limit(SESSION_SWEEP_BATCH)
133        .exec(&mut db)
134        .await
135        .map_err(infrastructure_failure)?
136        .into_iter()
137        .map(|row| row.token_hash)
138        .collect();
139    if expired.is_empty() {
140        return Ok(());
141    }
142    // The expiry is re-checked: the select and the delete are two statements.
143    AuthSession::filter(
144        AuthSession::fields()
145            .token_hash()
146            .in_list(expired)
147            .and(AuthSession::fields().expires_at().le(now)),
148    )
149    .delete()
150    .exec(&mut db)
151    .await
152    .map_err(infrastructure_failure)?;
153    Ok(())
154}
155
156/// Returns the request's live session row, purging it when expired and reading
157/// nothing when no session cookie is present.
158pub(super) async fn session_row(cx: &Cx) -> topcoat::Result<Option<AuthSession>> {
159    let Some(hash) = session::token_hash(cx).await? else {
160        return Ok(None);
161    };
162    let key = token_key(&hash);
163    let mut db = crate::db::db(cx);
164    let row = AuthSession::filter(AuthSession::fields().token_hash().eq(key))
165        .first()
166        .exec(&mut db)
167        .await
168        .map_err(infrastructure_failure)?;
169    let Some(row) = row else {
170        return Ok(None);
171    };
172    if row.expires_at <= Timestamp::now() {
173        delete_session_row(cx, &row.token_hash).await?;
174        return Ok(None);
175    }
176    Ok(Some(row))
177}
178
179/// Resolves `row` to its user, purging the row when the user no longer
180/// authenticates.
181pub(super) async fn session_user(
182    cx: &Cx,
183    row: AuthSession,
184    panel: &Arc<PanelState>,
185    authenticator: &dyn DynAuthenticator,
186) -> topcoat::Result<Option<SignedIn>> {
187    match authenticator
188        .find_by_id(cx, &row.user_id)
189        .await
190        .map_err(infrastructure_failure)?
191    {
192        Some(user) => Ok(Some(SignedIn {
193            user,
194            panel: Arc::clone(panel),
195            tenant: row.tenant,
196        })),
197        None => {
198            delete_session_row(cx, &row.token_hash).await?;
199            Ok(None)
200        }
201    }
202}
203
204/// Resolves the request's session into `panel`'s user; a session another panel
205/// issued resolves to no one here and stays valid there.
206pub(super) async fn resolve(
207    cx: &Cx,
208    panel: &Arc<PanelState>,
209    authenticator: &dyn DynAuthenticator,
210) -> topcoat::Result<Option<SignedIn>> {
211    match session_row(cx).await? {
212        Some(row) if row.panel == panel.prefix => session_user(cx, row, panel, authenticator).await,
213        _ => Ok(None),
214    }
215}