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}