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, _panel: &str) -> 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 at `panel`, its prefix with one leading slash and
59    /// none trailing: `/admin` for `Panel::new("admin")`. A user who may not answers 403,
60    /// indistinguishable from bad credentials at login. Every user type answers it: a sign-up on
61    /// one panel writes an account every panel over the same user type looks up.
62    ///
63    /// One user type can serve several panels, each with its own rule. Name each panel and refuse
64    /// the rest, so that a panel added or renamed later admits nobody until it has a rule:
65    ///
66    /// ```rust,ignore
67    /// fn can_access_panel(&self, panel: &str) -> bool {
68    ///     match panel {
69    ///         "/admin" => self.active && self.is_staff,
70    ///         "/portal" => self.active,
71    ///         _ => false,
72    ///     }
73    /// }
74    /// ```
75    ///
76    /// Every check of a panel asks it with that panel's prefix: the gate on each request, the
77    /// login, the sign-up, a mailed link and a two-factor challenge. The link that cancels an
78    /// email change does not ask: it only restores the old address and ends sessions.
79    fn can_access_panel(&self, panel: &str) -> bool;
80
81    /// The tenants the user may act for, in the order the tenant switcher
82    /// lists them. A session acts for the [`default_tenant`](Self::default_tenant)
83    /// until the user selects another. Defaults to none: the user has no tenant.
84    ///
85    /// A panel acts for tenants when it offers a tenant registration or settings page, or mounts
86    /// a resource over tenant-owned rows. Any other acts for no tenant:
87    /// [`tenant_id`](crate::tenant_id) answers `None` there, and the panel offers no tenant
88    /// menu and no route to switch.
89    ///
90    /// With two or more, on a panel that acts for tenants, a tenant menu sits atop the sidebar. It
91    /// posts to `{prefix}/tenant`, which stores the choice on the session and returns to the
92    /// panel's home page. A tenant the user is not a member of answers 403, and a stored choice
93    /// applies only while the membership lasts. One browser acts for one tenant at a time, in
94    /// every tab.
95    fn tenants(&self) -> &[Membership] {
96        &[]
97    }
98
99    /// The tenant a session acts for until the user selects another, while it is one of
100    /// [`tenants`](Self::tenants), such as one the user picks on their profile page and the app
101    /// stores. Defaults to none: the first of `tenants`.
102    fn default_tenant(&self) -> Option<TenantId> {
103        None
104    }
105
106    /// The time zone the user reads and enters wall-clock times in: a timestamp's control, its
107    /// [`DateColumn`](crate::DateColumn) day and its [`DateFilter`](crate::DateFilter) days.
108    /// Defaults to none: the panel's [`time_zone`](crate::Panel::time_zone).
109    fn time_zone(&self) -> Option<jiff::tz::TimeZone> {
110        None
111    }
112
113    /// The locale the user reads the panel and their mails in, such as `fr`. Defaults to none: the
114    /// browser's best match among the panel's [`locales`](crate::Panel::locales), else its
115    /// default. A locale matches the closest the panel offers, as `fr-CA` reads `fr`; one with no
116    /// match counts as none. A locale the user picks from the shell's language menu outranks this
117    /// one for their pages, while their mails still read this one, unless the authenticator
118    /// stores the pick through [`Authenticator::store_locale`] for this method to read.
119    ///
120    /// A user who stores their locale as text parses it:
121    ///
122    /// ```rust,ignore
123    /// fn locale(&self) -> Option<Locale> {
124    ///     self.locale.as_deref()?.parse().ok()
125    /// }
126    /// ```
127    fn locale(&self) -> Option<topcoat::l10n::Locale> {
128        None
129    }
130}
131
132/// How a panel loads its users (ADR-0013).
133///
134/// The default [`PasswordAuth`](crate::PasswordAuth) implements it against the shipped
135/// [`AdminUser`](super::AdminUser) model; an app with an existing user table implements it and
136/// passes the value to [`Panel::auth`](crate::Panel::auth) via
137/// [`Auth::custom`](crate::Auth::custom). Sessions are the framework's, so an implementation only
138/// maps credentials to a user and a stored id back to one. An app that signs its users in itself
139/// implements [`ExternalAuth`](crate::ExternalAuth) instead.
140///
141/// Every *credential* failure must return `Ok(None)`, never a distinguishable
142/// error: the login response is one generic message for all of them. An
143/// infrastructure failure is not a credential verdict, so an implementation
144/// that cannot reach its store returns the driver's error instead — the login
145/// handler maps it to the opaque outage page, which keeps a database outage
146/// from rendering as a rejected password. An implementation's own error keeps
147/// its own mapping.
148pub trait Authenticator: Send + Sync + 'static {
149    /// The app's user type.
150    type User: PanelUser;
151
152    /// Verify `login`/`password`, returning the user on success.
153    ///
154    /// Implementations must run comparable work for unknown accounts so
155    /// timing does not leak account existence; [`verify_password`](super::verify_password) does,
156    /// given `None` for an unknown account.
157    fn verify(
158        &self,
159        cx: &Cx,
160        login: &str,
161        password: &str,
162    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
163
164    /// Load the session's user by [`PanelUser::user_id`], with everything the request
165    /// reads from it — its [`tenants`](PanelUser::tenants) included.
166    ///
167    /// It runs on every request, which is what makes deactivation, revocation
168    /// and a removed membership take effect immediately; return `None` when
169    /// the user no longer authenticates.
170    fn find_by_id(
171        &self,
172        cx: &Cx,
173        id: &str,
174    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
175
176    /// Whether `password` is `user`'s current password: what a page asks before a change a stolen
177    /// session must not make, such as turning two-factor sign-in on.
178    ///
179    /// It defaults to [`verify`](Self::verify) with the user's [`email`](PanelUser::email) as the
180    /// login, and answers `false` for a user without one: override it when users sign in with
181    /// something else.
182    fn confirm_password(
183        &self,
184        cx: &Cx,
185        user: &Self::User,
186        password: &str,
187    ) -> impl Future<Output = topcoat::Result<bool>> + Send {
188        async move {
189            let Some(login) = user.email() else {
190                return Ok(false);
191            };
192            let verified = self.verify(cx, login, password).await?;
193            Ok(verified.is_some_and(|found| found.user_id() == user.user_id()))
194        }
195    }
196
197    /// Stores `locale` as `user`'s after they pick it from the shell's language menu, so their
198    /// [`locale`](PanelUser::locale) reads it on the next request, in another browser, and in the
199    /// mail a job sends them. It runs only when the pick differs from that locale, and never
200    /// while someone [impersonates](crate::Auth::impersonation) the user.
201    ///
202    /// It defaults to storing nothing: the pick then lives in the browser's cookie alone. A
203    /// failure is logged, and the page still switches to the locale picked.
204    fn store_locale(
205        &self,
206        cx: &Cx,
207        user: &Self::User,
208        locale: &topcoat::l10n::Locale,
209    ) -> impl Future<Output = topcoat::Result<()>> + Send {
210        let _ = (cx, user, locale);
211        async { Ok(()) }
212    }
213}