Skip to main content

tablo_core/auth/
mod.rs

1//! Authenticates panel users and gates panel routes.
2//!
3//! Gates panels by default; installs [`PasswordAuth`] or a custom [`Authenticator`].
4//! Reads the signed-in user through [`user`] and [`require_user`]. Stores sessions in
5//! [`AuthSession`] for every authenticator, scoped to the issuing panel.
6
7mod gate;
8mod login;
9mod password;
10mod session;
11
12use std::{
13    any::{Any, TypeId},
14    future::Future,
15    pin::Pin,
16    sync::Arc,
17};
18
19use topcoat::{
20    context::{Cx, try_request_context},
21    router::{
22        error::{forbidden, redirect, unauthorized},
23        request::{method, original_headers, original_method, uri},
24    },
25};
26use uuid::Uuid;
27
28pub(crate) use self::{
29    gate::{PanelGate, RuntimeGate},
30    login::{
31        MAX_LOGIN_BYTES, login_page, login_post, logout_post, logout_url, safe_next, tenant_post,
32        tenant_url,
33    },
34};
35pub use self::{
36    login::{LOGIN_FIELD, NEXT_FIELD, PASSWORD_FIELD, TENANT_FIELD},
37    password::{AdminUser, PasswordAuth, hash_password, verify_password},
38    session::{AuthSession, SESSION_LIFETIME, revoke_sessions_for_user},
39};
40use crate::{
41    panel::{
42        panel_prefix,
43        state::{PanelState, Panels, current, panels},
44    },
45    tenancy::Membership,
46};
47
48/// A signed-in user, as the panel knows it (ADR-0013).
49///
50/// Implement it for the app's own user type and return that type from an
51/// [`Authenticator`]; the shipped [`AdminUser`] implements it too. App code
52/// reads the user back with its own type through [`user`].
53///
54/// ```rust
55/// # struct Staff {
56/// #     id: uuid::Uuid,
57/// #     name: String,
58/// #     active: bool,
59/// #     memberships: Vec<tablo_core::Membership>,
60/// # }
61/// # use tablo_core::{Membership, PanelUser};
62/// impl PanelUser for Staff {
63///     fn user_id(&self) -> String {
64///         self.id.to_string()
65///     }
66///     fn display_name(&self) -> &str {
67///         &self.name
68///     }
69///     fn can_access_panel(&self) -> bool {
70///         self.active
71///     }
72///     fn tenants(&self) -> &[Membership] {
73///         &self.memberships
74///     }
75/// }
76/// ```
77pub trait PanelUser: Any + Send + Sync {
78    /// The stable key the session stores and
79    /// [`Authenticator::find_by_id`] receives. Any string fits.
80    fn user_id(&self) -> String;
81
82    /// The name the panel's top bar shows.
83    fn display_name(&self) -> &str;
84
85    /// Whether the user may enter the panel. A user who may not answers 403,
86    /// indistinguishable from bad credentials at login. Defaults to `true`.
87    fn can_access_panel(&self) -> bool {
88        true
89    }
90
91    /// The tenants the user may act for, in the order the tenant switcher
92    /// lists them. The first is the default tenant until the user selects
93    /// another. Defaults to none: the user has no tenant.
94    fn tenants(&self) -> &[Membership] {
95        &[]
96    }
97}
98
99/// How a panel loads its users (ADR-0013).
100///
101/// The default [`PasswordAuth`] implements it against the shipped
102/// [`AdminUser`] model; an app with an existing user table implements it and
103/// passes the value to [`Panel::auth`](crate::Panel::auth) via
104/// [`Auth::custom`]. Sessions are the framework's, so an implementation only
105/// maps credentials to a user and a stored id back to one.
106///
107/// Every *credential* failure must return `Ok(None)`, never a distinguishable
108/// error: the login response is one generic message for all of them. An
109/// infrastructure failure is not a credential verdict, so an implementation
110/// that cannot reach its store returns the driver's error instead — the login
111/// handler maps it to the opaque outage page, which keeps a database outage
112/// from rendering as a rejected password. An implementation's own error keeps
113/// its own mapping.
114pub trait Authenticator: Send + Sync + 'static {
115    /// The app's user type.
116    type User: PanelUser;
117
118    /// Verify `login`/`password`, returning the user on success.
119    ///
120    /// Implementations must run comparable work for unknown accounts so
121    /// timing does not leak account existence; [`verify_password`] does, given
122    /// `None` for an unknown account.
123    fn verify(
124        &self,
125        cx: &Cx,
126        login: &str,
127        password: &str,
128    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
129
130    /// Load the session's user by [`PanelUser::user_id`], with everything the request
131    /// reads from it — its [`tenants`](PanelUser::tenants) included.
132    ///
133    /// It runs on every request, which is what makes deactivation, revocation
134    /// and a removed membership take effect immediately; return `None` when
135    /// the user no longer authenticates.
136    fn find_by_id(
137        &self,
138        cx: &Cx,
139        id: &str,
140    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
141}
142
143/// The boxed future the erased authenticator returns.
144type UserFuture<'a> =
145    Pin<Box<dyn Future<Output = topcoat::Result<Option<Arc<dyn PanelUser>>>> + Send + 'a>>;
146
147/// [`Authenticator`] with its user type erased, as the panel keeps it.
148pub(crate) trait DynAuthenticator: Send + Sync {
149    fn verify<'a>(&'a self, cx: &'a Cx, login: &'a str, password: &'a str) -> UserFuture<'a>;
150    fn find_by_id<'a>(&'a self, cx: &'a Cx, id: &'a str) -> UserFuture<'a>;
151    /// The [`TypeId`] of [`Authenticator::User`], so mounting knows when the
152    /// shipped [`AdminUser`] must be registered.
153    fn user_type(&self) -> TypeId;
154}
155
156impl<A: Authenticator> DynAuthenticator for A {
157    fn verify<'a>(&'a self, cx: &'a Cx, login: &'a str, password: &'a str) -> UserFuture<'a> {
158        Box::pin(async move {
159            let user = Authenticator::verify(self, cx, login, password).await?;
160            Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
161        })
162    }
163
164    fn find_by_id<'a>(&'a self, cx: &'a Cx, id: &'a str) -> UserFuture<'a> {
165        Box::pin(async move {
166            let user = Authenticator::find_by_id(self, cx, id).await?;
167            Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
168        })
169    }
170
171    fn user_type(&self) -> TypeId {
172        TypeId::of::<A::User>()
173    }
174}
175
176/// The panel's authentication configuration (ADR-0013).
177///
178/// The default is [`Auth::password`]; [`Auth::custom`] installs an app-owned
179/// [`Authenticator`]; [`Auth::disabled`] is the explicit fail-open opt-out.
180pub struct Auth(Option<Box<dyn DynAuthenticator>>);
181
182impl Auth {
183    /// The shipped Argon2id + [`AdminUser`] authenticator.
184    #[must_use]
185    pub fn password() -> Self {
186        Self::custom(PasswordAuth)
187    }
188
189    /// An app-owned authenticator over its own user table.
190    #[must_use]
191    pub fn custom(authenticator: impl Authenticator) -> Self {
192        Self(Some(Box::new(authenticator)))
193    }
194
195    /// Explicit fail-open opt-out for public demos (ADR-0013): no gate, no
196    /// login routes, sessions unused.
197    #[must_use]
198    pub fn disabled() -> Self {
199        Self(None)
200    }
201
202    /// Whether the explicit opt-out is set.
203    #[must_use]
204    pub fn is_disabled(&self) -> bool {
205        self.0.is_none()
206    }
207
208    /// The authenticator, or `None` when auth is disabled.
209    pub(crate) fn authenticator(&self) -> Option<&dyn DynAuthenticator> {
210        self.0.as_deref()
211    }
212}
213
214impl Default for Auth {
215    fn default() -> Self {
216        Self::password()
217    }
218}
219
220impl std::fmt::Debug for Auth {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        f.write_str(if self.is_disabled() {
223            "Auth::disabled"
224        } else {
225            "Auth"
226        })
227    }
228}
229
230/// The user a panel's gate resolved, as request `Cx` carries it.
231#[derive(Clone)]
232pub(crate) struct SignedIn {
233    pub(crate) user: Arc<dyn PanelUser>,
234    /// The panel whose session signed the user in: the only panel the user
235    /// is the user of.
236    pub(crate) panel: Arc<PanelState>,
237    /// The tenant the session selected, if any. [`tenant_id`](crate::tenant_id)
238    /// honors it only while it is one of the user's memberships.
239    pub(crate) tenant: Option<Uuid>,
240}
241
242/// The request's signed-in user on the request's panel, whatever their panel
243/// access. Only the logout route reads a user without access.
244pub(crate) fn resolved(cx: &Cx) -> Option<&SignedIn> {
245    let signed = try_request_context::<SignedIn>(cx)?;
246    match current(cx) {
247        Some(panel) if !Arc::ptr_eq(&signed.panel, panel) => None,
248        _ => Some(signed),
249    }
250}
251
252/// The request's signed-in user, erased: on the request's panel, with panel
253/// access.
254pub(crate) fn signed(cx: &Cx) -> Option<&SignedIn> {
255    resolved(cx).filter(|signed| signed.user.can_access_panel())
256}
257
258/// The signed-in user's membership the request acts under: the tenant the
259/// session selected while it is still one of the user's
260/// [`tenants`](PanelUser::tenants), else the first. `None` without a
261/// signed-in user, for a user with no tenant, and when a `Tenant` override
262/// names a tenant the user is not a member of.
263pub fn membership(cx: &Cx) -> Option<&Membership> {
264    let tenant = crate::tenant_id(cx)?;
265    signed(cx)?
266        .user
267        .tenants()
268        .iter()
269        .find(|m| m.tenant == tenant)
270}
271
272/// The tenant of the session's selected membership, else of the user's first: the
273/// [`TenantSource`](crate::tenancy::TenantSource) the panel installs.
274pub(crate) fn session_tenant(cx: &Cx) -> Option<Uuid> {
275    let signed = signed(cx)?;
276    let tenants = signed.user.tenants();
277    signed
278        .tenant
279        .and_then(|tenant| tenants.iter().find(|m| m.tenant == tenant))
280        .or_else(|| tenants.first())
281        .map(|membership| membership.tenant)
282}
283
284/// The signed-in user, as the app's own user type.
285///
286/// `None` when nobody is signed in to the request's panel, and when the
287/// panel's [`Authenticator`] loads another type than `U` — a helper shared by
288/// two panels with different user types answers `None` on the other one.
289///
290/// ```rust
291/// # struct Staff { admin: bool }
292/// # use tablo_core::{PanelUser, auth};
293/// # use topcoat::context::Cx;
294/// # impl PanelUser for Staff {
295/// #     fn user_id(&self) -> String { String::new() }
296/// #     fn display_name(&self) -> &str { "" }
297/// # }
298/// fn admins_only(cx: &Cx) -> bool {
299///     auth::user::<Staff>(cx).is_some_and(|staff| staff.admin)
300/// }
301/// ```
302pub fn user<U: PanelUser>(cx: &Cx) -> Option<&U> {
303    let user: &dyn Any = &*signed(cx)?.user;
304    user.downcast_ref()
305}
306
307/// Require the signed-in user, as the app's own user type.
308///
309/// # Errors
310///
311/// Answers per request kind (ADR-0013): pages redirect to the login route with
312/// a same-origin-relative `next`; runtime endpoints, non-GET requests, and
313/// page re-runs (marked POSTs the runtime layer rewrites into GETs) answer
314/// 401. A signed-in user of another type than `U` answers 403.
315pub fn require_user<U: PanelUser>(cx: &Cx) -> topcoat::Result<&U> {
316    match signed(cx) {
317        Some(_) => user(cx).ok_or_else(|| forbidden().into()),
318        None => Err(unauthenticated_error(cx)),
319    }
320}
321
322/// Whether a user is signed in to the request's panel.
323pub fn signed_in(cx: &Cx) -> bool {
324    signed(cx).is_some()
325}
326
327/// Whether the request's panel requires a signed-in user.
328///
329/// Outside any panel — an app's own shard or page on a router that mounts
330/// several — it answers whether some mounted panel does, so a check there
331/// fails closed.
332pub fn enforced(cx: &Cx) -> bool {
333    match current(cx) {
334        Some(panel) => panel.gates(),
335        None => panels(cx).is_some_and(Panels::any_gates),
336    }
337}
338
339/// Require a signed-in user when the request's panel requires sign-in.
340///
341/// Every panel handler runs it first, and a page, route or shard the app
342/// serves under a panel runs it to answer exactly as the panel's own pages do.
343/// Shards are the case that needs it: Topcoat serves them at its runtime path,
344/// where no page guard runs. A form route also verifies its CSRF token with
345/// [`csrf::verify`](crate::csrf::verify).
346///
347/// # Errors
348///
349/// 401, or a redirect to the login page, as [`require_user`] answers.
350pub fn guard(cx: &Cx) -> topcoat::Result<()> {
351    if enforced(cx) && !signed_in(cx) {
352        return Err(unauthenticated_error(cx));
353    }
354    Ok(())
355}
356
357/// The error an unauthenticated request answers with, by request kind.
358fn unauthenticated_error(cx: &Cx) -> topcoat::Error {
359    let path = uri(cx).path();
360    let page_method = matches!(*method(cx), http::Method::GET | http::Method::HEAD);
361    // A page re-run reaches the gate as a rewritten GET: Topcoat's runtime
362    // layer rewrites the browser's marked POST into a GET for the page's own
363    // URL. The original request tells it apart from a plain page load, so a
364    // logged-out re-run answers 401 like any other non-page request instead
365    // of redirecting into the login page.
366    let rerun = !matches!(*original_method(cx), http::Method::GET | http::Method::HEAD)
367        && original_headers(cx).get(&topcoat::runtime::RUNTIME_HEADER) == Some(&RERUN_MARKER);
368    if path.starts_with(crate::topcoat_compat::RUNTIME_PREFIX) || !page_method || rerun {
369        unauthorized().into()
370    } else {
371        redirect(login::login_url_with_next(cx)).into()
372    }
373}
374
375/// The panel root: where a completed login or tenant switch lands.
376fn panel_root(cx: &Cx) -> String {
377    panel_prefix(cx)
378}
379
380/// Map a failed auth or session operation to the error the response carries:
381/// [`crate::error::driver_failure`] with the sign-in outage copy.
382///
383/// A driver failure becomes an infrastructure error whose `Display` is
384/// [`UNAVAILABLE_ERROR`], and the driver's own text goes to the log, never to
385/// the page; anything else is app-authored (a custom [`Authenticator`]'s own
386/// error) and keeps its mapping. Every auth and session path maps through
387/// here, which is what keeps a database outage from reading as a rejected
388/// password — and what makes the two distinguishable in the logs: a rejection
389/// is `Ok(None)`, the generic 403 and no error line, while an outage logs the
390/// driver's text.
391fn infrastructure_failure(error: impl Into<topcoat::Error>) -> topcoat::Error {
392    crate::error::driver_failure(error, UNAVAILABLE_ERROR)
393}
394
395/// What a failed login attempt renders when the database behind it could not
396/// answer.
397///
398/// The credential rejection is deliberately non-specific about *why*
399/// credentials were refused, so reusing it for an outage would tell a user
400/// their password was wrong while the database was down — the one place they
401/// would retry uselessly. This copy names the machinery that is down instead,
402/// and like the generic one it never carries driver text: that goes to the
403/// log.
404const UNAVAILABLE_ERROR: &str = "Sign-in is unavailable right now. Try again shortly.";
405
406/// The runtime-header value marking a page re-run POST (`true`, per Topcoat's
407/// page-rerun protocol).
408static RERUN_MARKER: http::HeaderValue = http::HeaderValue::from_static("true");
409
410/// Fail loudly at mount when a required shipped model is missing from the
411/// app's `Db` (ADR-0013): the table is never pushed, and the first login
412/// would otherwise be a confusing runtime error.
413pub(crate) fn check_models_registered(
414    db: &toasty::Db,
415    auth: &Auth,
416) -> Result<(), crate::DeclarationErrorKind> {
417    let Some(authenticator) = auth.authenticator() else {
418        return Ok(());
419    };
420    let registered = |name: &str| {
421        db.schema()
422            .app
423            .models()
424            .any(|model| model.name().upper_camel_case() == name)
425    };
426    let shipped_user = authenticator.user_type() == TypeId::of::<AdminUser>();
427    let models: Vec<&'static str> = [
428        (!registered("AuthSession")).then_some("AuthSession"),
429        (shipped_user && !registered("AdminUser")).then_some("AdminUser"),
430    ]
431    .into_iter()
432    .flatten()
433    .collect();
434    if models.is_empty() {
435        Ok(())
436    } else {
437        Err(crate::DeclarationErrorKind::MissingAuthModels { models })
438    }
439}
440
441#[cfg(test)]
442mod tests;