Skip to main content

tablo_core/auth/
panel_user.rs

1//! [`PanelUser`] and the [`Authenticator`] that loads one.
2
3use std::{any::Any, future::Future};
4
5use topcoat::context::Cx;
6
7use crate::tenancy::{Membership, TenantId};
8
9/// A signed-in user, as the panel knows it (ADR-0013).
10///
11/// Implement it for the app's own user type and return that type from an
12/// [`Authenticator`]; the shipped [`AdminUser`](super::AdminUser) implements it too. App code
13/// reads the user back with its own type through [`user`](super::user).
14///
15/// ```rust
16/// # struct Staff {
17/// #     id: uuid::Uuid,
18/// #     name: String,
19/// #     active: bool,
20/// #     memberships: Vec<tablo_core::Membership>,
21/// # }
22/// # use tablo_core::{Membership, PanelUser};
23/// impl PanelUser for Staff {
24///     fn user_id(&self) -> String {
25///         self.id.to_string()
26///     }
27///     fn display_name(&self) -> &str {
28///         &self.name
29///     }
30///     fn can_access_panel(&self) -> bool {
31///         self.active
32///     }
33///     fn tenants(&self) -> &[Membership] {
34///         &self.memberships
35///     }
36/// }
37/// ```
38pub trait PanelUser: Any + Send + Sync {
39    /// The stable key the session stores and
40    /// [`Authenticator::find_by_id`] receives. Any string fits.
41    fn user_id(&self) -> String;
42
43    /// The name the user menu shows.
44    fn display_name(&self) -> &str;
45
46    /// The muted line under the name in the user menu, such as an email or a role. Defaults to
47    /// none.
48    fn subtitle(&self) -> Option<&str> {
49        None
50    }
51
52    /// The address the account stores, which mail meant for the account goes to: a reset link,
53    /// a verification link, a sign-in code. Defaults to none: the account gets no mail.
54    fn email(&self) -> Option<&str> {
55        None
56    }
57
58    /// Whether the user may enter the panel. A user who may not answers 403,
59    /// indistinguishable from bad credentials at login. Defaults to `true`.
60    fn can_access_panel(&self) -> bool {
61        true
62    }
63
64    /// The tenants the user may act for, in the order the tenant switcher
65    /// lists them. A session acts for the [`default_tenant`](Self::default_tenant)
66    /// until the user selects another. Defaults to none: the user has no tenant.
67    ///
68    /// With two or more, a tenant menu sits atop the sidebar. It posts to `{prefix}/tenant`, which
69    /// stores the choice on the session and returns to the panel's home page. A tenant the user
70    /// is not a member of answers 403, and a stored choice applies only while the membership
71    /// lasts. One browser acts for one tenant at a time, in every tab.
72    fn tenants(&self) -> &[Membership] {
73        &[]
74    }
75
76    /// The tenant a session acts for until the user selects another, while it is one of
77    /// [`tenants`](Self::tenants), such as one the user picks on their profile page and the app
78    /// stores. Defaults to none: the first of `tenants`.
79    fn default_tenant(&self) -> Option<TenantId> {
80        None
81    }
82
83    /// The time zone the user reads and enters wall-clock times in: a timestamp's control, its
84    /// [`DateColumn`](crate::DateColumn) day and its [`DateFilter`](crate::DateFilter) days.
85    /// Defaults to none: the panel's [`time_zone`](crate::Panel::time_zone).
86    fn time_zone(&self) -> Option<jiff::tz::TimeZone> {
87        None
88    }
89
90    /// The locale the user reads the panel and their mails in, such as `fr`. Defaults to none: the
91    /// browser's best match among the panel's [`locales`](crate::Panel::locales), else its
92    /// default. A locale matches the closest the panel offers, as `fr-CA` reads `fr`; one with no
93    /// match counts as none.
94    ///
95    /// A user who stores their locale as text parses it:
96    ///
97    /// ```rust,ignore
98    /// fn locale(&self) -> Option<Locale> {
99    ///     self.locale.as_deref()?.parse().ok()
100    /// }
101    /// ```
102    fn locale(&self) -> Option<topcoat::l10n::Locale> {
103        None
104    }
105}
106
107/// How a panel loads its users (ADR-0013).
108///
109/// The default [`PasswordAuth`](crate::PasswordAuth) implements it against the shipped
110/// [`AdminUser`](super::AdminUser) model; an app with an existing user table implements it and
111/// passes the value to [`Panel::auth`](crate::Panel::auth) via
112/// [`Auth::custom`](crate::Auth::custom). Sessions are the framework's, so an implementation only
113/// maps credentials to a user and a stored id back to one.
114///
115/// Every *credential* failure must return `Ok(None)`, never a distinguishable
116/// error: the login response is one generic message for all of them. An
117/// infrastructure failure is not a credential verdict, so an implementation
118/// that cannot reach its store returns the driver's error instead — the login
119/// handler maps it to the opaque outage page, which keeps a database outage
120/// from rendering as a rejected password. An implementation's own error keeps
121/// its own mapping.
122pub trait Authenticator: Send + Sync + 'static {
123    /// The app's user type.
124    type User: PanelUser;
125
126    /// Verify `login`/`password`, returning the user on success.
127    ///
128    /// Implementations must run comparable work for unknown accounts so
129    /// timing does not leak account existence; [`verify_password`](super::verify_password) does,
130    /// given `None` for an unknown account.
131    fn verify(
132        &self,
133        cx: &Cx,
134        login: &str,
135        password: &str,
136    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
137
138    /// Load the session's user by [`PanelUser::user_id`], with everything the request
139    /// reads from it — its [`tenants`](PanelUser::tenants) included.
140    ///
141    /// It runs on every request, which is what makes deactivation, revocation
142    /// and a removed membership take effect immediately; return `None` when
143    /// the user no longer authenticates.
144    fn find_by_id(
145        &self,
146        cx: &Cx,
147        id: &str,
148    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
149
150    /// Whether `password` is `user`'s current password: what a page asks before a change a stolen
151    /// session must not make, such as turning two-factor sign-in on.
152    ///
153    /// It defaults to [`verify`](Self::verify) with the user's [`email`](PanelUser::email) as the
154    /// login, and answers `false` for a user without one: override it when users sign in with
155    /// something else.
156    fn confirm_password(
157        &self,
158        cx: &Cx,
159        user: &Self::User,
160        password: &str,
161    ) -> impl Future<Output = topcoat::Result<bool>> + Send {
162        async move {
163            let Some(login) = user.email() else {
164                return Ok(false);
165            };
166            let verified = self.verify(cx, login, password).await?;
167            Ok(verified.is_some_and(|found| found.user_id() == user.user_id()))
168        }
169    }
170}