Skip to main content

tablo_core/auth/
impersonation.rs

1//! [`Auth::impersonation`]: a panel user acting as another, to see the panel as they see it, for a
2//! short while and never as far as their credentials.
3
4use std::{
5    any::Any,
6    sync::Arc,
7    time::{Duration, SystemTime},
8};
9
10use jiff::Timestamp;
11use topcoat::{
12    context::Cx,
13    icon::icon,
14    router::{
15        Body, RouteFuture,
16        error::{forbidden, see_other},
17        request::uri,
18        response::IntoResponse,
19    },
20    view::{BoxView, ViewExt, attributes, view},
21};
22
23use super::{
24    Auth, AuthConfig, DynAuthenticator, PanelUser, SignedIn, infrastructure_failure, panel_root,
25    realm::{Realm, panel_prefix},
26    session::{self, AuthSession},
27    signed, unauthenticated_error,
28};
29use crate::AuditEvent;
30
31/// The field of a `POST {prefix}/impersonate` form naming the user to act as: their
32/// [`PanelUser::user_id`].
33pub const IMPERSONATE_FIELD: &str = "user";
34
35/// The rule an [`Impersonation`] asks: whether `actor` may act as `target`.
36type Rule<U> = dyn Fn(&Cx, &U, &U) -> bool + Send + Sync;
37
38/// Who a panel's users may act as, and for how long, set by [`Auth::impersonation`].
39///
40/// ```rust
41/// # use tablo_core::{Auth, Impersonation, auth::AdminUser};
42/// # fn is_support(user: &AdminUser) -> bool { user.email.ends_with("@support.example") }
43/// let auth = Auth::password().impersonation(
44///     // Support acts as customers, never as another member of support.
45///     Impersonation::new(|_cx, actor: &AdminUser, target: &AdminUser| {
46///         is_support(actor) && !is_support(target)
47///     })
48///     .lifetime(std::time::Duration::from_secs(30 * 60)),
49/// );
50/// ```
51pub struct Impersonation<U> {
52    allows: Arc<Rule<U>>,
53    lifetime: Duration,
54    across_tenants: bool,
55}
56
57/// The longest an [`Impersonation`] lasts, whatever its [`lifetime`](Impersonation::lifetime).
58const LONGEST: Duration = Duration::from_hours(24);
59
60impl<U: PanelUser> Impersonation<U> {
61    /// Lets a user act as another when `allows(cx, actor, target)` answers `true`, for an hour.
62    ///
63    /// It is asked again on every request while the actor acts as the target, so a change to
64    /// either user, or to the rule, ends it at once. A user never acts as themselves, nor as a
65    /// user who may not enter the panel, whatever it answers; on a panel that acts for tenants,
66    /// nor as a user who belongs to a tenant the actor does not, unless
67    /// [`across_tenants`](Self::across_tenants) says otherwise.
68    ///
69    /// Decide from `actor` and `target` alone: `cx` carries the request, but whom
70    /// [`auth::user`](crate::auth::user) names inside it depends on when the rule is asked.
71    pub fn new(allows: impl Fn(&Cx, &U, &U) -> bool + Send + Sync + 'static) -> Self {
72        Self {
73            allows: Arc::new(allows),
74            lifetime: Duration::from_hours(1),
75            across_tenants: false,
76        }
77    }
78
79    /// How long acting as a user lasts before the actor is themselves again; defaults to an hour,
80    /// and never exceeds a day.
81    #[must_use]
82    pub fn lifetime(mut self, lifetime: Duration) -> Self {
83        self.lifetime = lifetime.min(LONGEST);
84        self
85    }
86
87    /// Let the actor act as a user of tenants they are no member of, such as a platform's own
88    /// staff acting as any customer. Off by default: on a panel that acts for tenants, every
89    /// tenant the target belongs to must be one of the actor's, so acting as them reaches no
90    /// tenant the actor could not already.
91    #[must_use]
92    pub fn across_tenants(mut self) -> Self {
93        self.across_tenants = true;
94        self
95    }
96}
97
98impl<U: PanelUser> Auth<U> {
99    /// Lets a signed-in user act as another user of the panel, as [`Impersonation`] allows: to see
100    /// what they see, and to reproduce what they report.
101    ///
102    /// - `POST {prefix}/impersonate`, carrying the CSRF field and [`IMPERSONATE_FIELD`], starts it,
103    ///   and [`Impersonate`](crate::Impersonate) places a button for it on a users resource. It
104    ///   answers 403 to a user already acting as another, and for a target the rule refuses or no
105    ///   account matches, alike.
106    /// - Every page then reads the target as the signed-in user, tenant included, under a banner
107    ///   naming them with a Stop button, which posts to `{prefix}/impersonate/stop` and restores
108    ///   the actor. So does the end of the lifetime, or the rule refusing again.
109    /// - The profile, two-factor, tenant settings, tenant registration and email-verification pages
110    ///   answer 403 while it lasts, and an email-change link ignores the session: the actor never
111    ///   changes the target's credentials.
112    /// - A panel that keeps an [audit log](crate::Panel::audit_log) records `impersonate` and
113    ///   `stop_impersonating` for the actor, keyed by the target's id, and names the actor, not the
114    ///   target, on every write made meanwhile. `impersonate` names, as its `until` change, when it
115    ///   ends at the latest. Stop, a sign-out, and the first request past the end or after the rule
116    ///   refuses record `stop_impersonating`; a session that ends unseen records none, and its
117    ///   `until` says when it ended.
118    ///
119    /// The session row stores the target in two columns, see [`AuthSession`]. Mounting refuses it
120    /// behind [`Auth::external`].
121    #[must_use]
122    pub fn impersonation(mut self, impersonation: Impersonation<U>) -> Self {
123        let allows = impersonation.allows;
124        self.config.impersonation = Some(ImpersonationConfig {
125            allows: Arc::new(move |cx, actor, target| {
126                let (actor, target): (&dyn Any, &dyn Any) = (actor, target);
127                match (actor.downcast_ref::<U>(), target.downcast_ref::<U>()) {
128                    (Some(actor), Some(target)) => allows(cx, actor, target),
129                    _ => false,
130                }
131            }),
132            lifetime: impersonation.lifetime,
133            across_tenants: impersonation.across_tenants,
134        });
135        self
136    }
137}
138
139type Allows = dyn Fn(&Cx, &dyn PanelUser, &dyn PanelUser) -> bool + Send + Sync;
140
141/// An [`Impersonation`] with its user type erased.
142#[derive(Clone)]
143pub(crate) struct ImpersonationConfig {
144    allows: Arc<Allows>,
145    lifetime: Duration,
146    across_tenants: bool,
147}
148
149impl std::fmt::Debug for ImpersonationConfig {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.debug_struct("Impersonation")
152            .field("lifetime", &self.lifetime)
153            .field("across_tenants", &self.across_tenants)
154            .finish_non_exhaustive()
155    }
156}
157
158impl ImpersonationConfig {
159    /// Whether `actor` may act as `target` on `panel`: two users, both admitted to it, the target
160    /// in no tenant the actor is not, on a panel that acts for tenants, and the app's rule
161    /// agreeing.
162    fn permits(
163        &self,
164        cx: &Cx,
165        panel: &Realm,
166        actor: &dyn PanelUser,
167        target: &dyn PanelUser,
168    ) -> bool {
169        let within_tenants = !panel.serves_tenants
170            || self.across_tenants
171            || target.tenants().iter().all(|membership| {
172                actor
173                    .tenants()
174                    .iter()
175                    .any(|own| own.tenant == membership.tenant)
176            });
177        actor.user_id() != target.user_id()
178            && actor.can_access_panel(&panel.prefix)
179            && target.can_access_panel(&panel.prefix)
180            && within_tenants
181            && (self.allows)(cx, actor, target)
182    }
183}
184
185impl AuthConfig {
186    /// The impersonation rule, or `None` when no user may act as another.
187    pub(crate) fn impersonation(&self) -> Option<&ImpersonationConfig> {
188        self.authenticator().and(self.impersonation.as_ref())
189    }
190}
191
192/// The user acting as the signed-in user, as `U`; `None` while the signed-in user acts as
193/// themselves, or when the actor is not a `U`.
194pub fn impersonator<U: PanelUser>(cx: &Cx) -> Option<&U> {
195    let actor: &dyn Any = &**signed(cx)?.impersonator.as_ref()?;
196    actor.downcast_ref()
197}
198
199/// Resolves a session whose `actor` acts as another user: the target, with the actor beside, while
200/// the lifetime lasts and the rule still allows it; else the actor, the row cleared.
201pub(super) async fn resolve(
202    cx: &Cx,
203    row: AuthSession,
204    actor: Arc<dyn PanelUser>,
205    panel: &Arc<Realm>,
206    authenticator: &dyn DynAuthenticator,
207) -> topcoat::Result<SignedIn> {
208    if let Some(target) = live_target(cx, &row, &*actor, panel, authenticator).await? {
209        return Ok(SignedIn {
210            user: target,
211            realm: Arc::clone(panel),
212            tenant: row.tenant,
213            impersonator: Some(actor),
214        });
215    }
216    // Ended by its lifetime, the rule or either user: cleared and recorded together.
217    let mut db = crate::db::db(cx)?;
218    let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
219    session::set_impersonation(&mut tx, &row.token_hash, None).await?;
220    if let Some(target) = row.impersonating.as_deref() {
221        let tenant = row.tenant;
222        record(
223            cx,
224            &mut tx,
225            panel,
226            tenant,
227            "stop_impersonating",
228            &*actor,
229            target,
230            None,
231            None,
232        )
233        .await?;
234    }
235    tx.commit().await.map_err(infrastructure_failure)?;
236    Ok(SignedIn {
237        user: actor,
238        realm: Arc::clone(panel),
239        tenant: None,
240        impersonator: None,
241    })
242}
243
244async fn live_target(
245    cx: &Cx,
246    row: &AuthSession,
247    actor: &dyn PanelUser,
248    panel: &Realm,
249    authenticator: &dyn DynAuthenticator,
250) -> topcoat::Result<Option<Arc<dyn PanelUser>>> {
251    let (Some(id), Some(until), Some(config)) = (
252        row.impersonating.as_deref(),
253        row.impersonating_until,
254        panel.auth.impersonation(),
255    ) else {
256        return Ok(None);
257    };
258    // An actor who owes a step, such as setting up a factor the panel now requires, goes to it.
259    if until <= Timestamp::now() || super::gate::owes_step(cx, panel, actor).await? {
260        return Ok(None);
261    }
262    let target = authenticator
263        .find_by_id(cx, id)
264        .await
265        .map_err(infrastructure_failure)?;
266    Ok(target.filter(|target| config.permits(cx, panel, actor, &**target)))
267}
268
269/// The signed-in user who may start acting as another, with the panel's rule: one acting as
270/// themselves, on a panel with [`Auth::impersonation`].
271fn actor(cx: &Cx) -> Option<(&SignedIn, &ImpersonationConfig)> {
272    let signed = signed(cx).filter(|signed| signed.impersonator.is_none())?;
273    Some((signed, signed.realm.auth.impersonation()?))
274}
275
276/// A button that acts as `target`, for a page of the app's own, such as a staff list over a user
277/// type that is no model, where the [`Impersonate`](crate::Impersonate) action cannot go: a form
278/// that posts `target`'s [`user_id`](PanelUser::user_id) to `{prefix}/impersonate` with the CSRF
279/// field.
280///
281/// It renders nothing unless the signed-in user may act as `target` under the panel's
282/// [`Impersonation`] rule, as the action's button shows only then; the route asks the rule again.
283///
284/// ```rust
285/// # use tablo_core::{PanelUser, auth};
286/// # use topcoat::{context::Cx, view::{View, view}};
287/// fn act_as<'a>(cx: &'a Cx, colleague: &dyn PanelUser) -> impl View + 'a {
288///     let button = auth::impersonate_button(cx, colleague);
289///     view! { <td>(button)</td> }
290/// }
291/// ```
292pub fn impersonate_button<'a>(cx: &'a Cx, target: &dyn PanelUser) -> BoxView<'a> {
293    if !may_impersonate(cx, target) {
294        return view! {}.boxed();
295    }
296    let token = crate::csrf::ensure_token(cx);
297    let action = format!("{}/impersonate", panel_prefix(cx));
298    let id = target.user_id();
299    view! {
300        <form method="post" action=(action) data-impersonate=(id.clone())>
301            (crate::csrf::field(cx, &token))
302            <input type="hidden" name=(IMPERSONATE_FIELD) value=(id)>
303            tablo_ui::button(
304                variant: tablo_ui::ButtonVariant::Outline,
305                size: tablo_ui::ButtonSize::Sm,
306                attrs: attributes! { type="submit" },
307                icon(data: tablo_ui::icons::EYE)
308                (crate::t!(cx, "tablo-impersonation-start"))
309            )
310        </form>
311    }
312    .boxed()
313}
314
315/// Whether the signed-in user may act as `target`: what shows the
316/// [`Impersonate`](crate::Impersonate) button.
317pub(crate) fn may_impersonate(cx: &Cx, target: &dyn PanelUser) -> bool {
318    actor(cx)
319        .is_some_and(|(signed, config)| config.permits(cx, &signed.realm, &*signed.user, target))
320}
321
322/// Makes the request's session act as `target` through `ex`, and records it through `ex` too, so
323/// a transaction commits both or neither; 403 when the signed-in user may not.
324pub(crate) async fn start(
325    cx: &Cx,
326    ex: &mut dyn toasty::Executor,
327    target: &dyn PanelUser,
328) -> topcoat::Result<()> {
329    let Some((signed, config)) = actor(cx) else {
330        return Err(forbidden().into());
331    };
332    if !config.permits(cx, &signed.realm, &*signed.user, target) {
333        return Err(forbidden().into());
334    }
335    let until =
336        Timestamp::try_from(SystemTime::now() + config.lifetime).map_err(topcoat::Error::from)?;
337    session::impersonate(cx, &mut *ex, Some((target.user_id(), until))).await?;
338    let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
339    let (id, name) = (target.user_id(), Some(target.display_name()));
340    record(
341        cx,
342        ex,
343        realm,
344        tenant,
345        "impersonate",
346        &*signed.user,
347        &id,
348        name,
349        Some(until),
350    )
351    .await?;
352    crate::bus::bus(cx).wake(&signed.realm.prefix, &signed.user.user_id());
353    Ok(())
354}
355
356/// `POST {prefix}/impersonate`: act as the user [`IMPERSONATE_FIELD`] names, then land on the
357/// panel root.
358pub(super) fn start_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
359    Box::pin(async move {
360        let Some(signed) = signed(cx) else {
361            return Err(unauthenticated_error(cx));
362        };
363        let parts = super::input::read_form(cx, body).await?;
364        crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
365        let authenticator = signed.realm.auth.authenticator().ok_or_else(forbidden)?;
366        let id = parts.last(IMPERSONATE_FIELD).ok_or_else(forbidden)?;
367        // An unknown id answers as a refused one does, so the route tells no one who exists.
368        let target = authenticator
369            .find_by_id(cx, id)
370            .await
371            .map_err(infrastructure_failure)?
372            .ok_or_else(forbidden)?;
373        let mut db = crate::db::db(cx)?;
374        let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
375        start(cx, &mut tx, &*target).await?;
376        tx.commit().await.map_err(infrastructure_failure)?;
377        see_other(panel_root(cx)).into_response(cx)
378    })
379}
380
381/// `POST {prefix}/impersonate/stop`: the actor is themselves again, on the panel root.
382pub(super) fn stop_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
383    Box::pin(async move {
384        let Some(signed) = signed(cx) else {
385            return Err(unauthenticated_error(cx));
386        };
387        let parts = super::input::read_form(cx, body).await?;
388        crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
389        if let Some(actor) = &signed.impersonator {
390            let mut db = crate::db::db(cx)?;
391            let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
392            session::impersonate(cx, &mut tx, None).await?;
393            let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
394            let (id, name) = (signed.user.user_id(), Some(signed.user.display_name()));
395            let event = "stop_impersonating";
396            record(cx, &mut tx, realm, tenant, event, &**actor, &id, name, None).await?;
397            tx.commit().await.map_err(infrastructure_failure)?;
398            crate::bus::bus(cx).wake(&signed.realm.prefix, &actor.user_id());
399        }
400        see_other(panel_root(cx)).into_response(cx)
401    })
402}
403
404/// Records that `signed` stops acting as another user, when they do, as they sign out: the stop a
405/// sign-out ends without the Stop button.
406pub(super) async fn stop_on_sign_out(cx: &Cx, signed: &SignedIn) -> topcoat::Result<()> {
407    let Some(actor) = &signed.impersonator else {
408        return Ok(());
409    };
410    let mut db = crate::db::db(cx)?;
411    let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
412    let (id, name) = (signed.user.user_id(), Some(signed.user.display_name()));
413    let event = "stop_impersonating";
414    record(cx, &mut db, realm, tenant, event, &**actor, &id, name, None).await
415}
416
417/// Where the banner's Stop button posts: `{prefix}/impersonate/stop`.
418pub(crate) fn stop_url(cx: &Cx) -> String {
419    format!("{}/impersonate/stop", panel_prefix(cx))
420}
421
422/// Records `event`, done by `actor` to the user of `target_id`, named `target_name` when known, in
423/// `panel`'s audit log for `tenant`, when it keeps one; a start names `until`, when it ends on its
424/// own.
425#[expect(
426    clippy::too_many_arguments,
427    reason = "one call per event, each naming every part"
428)]
429async fn record(
430    cx: &Cx,
431    ex: &mut dyn toasty::Executor,
432    panel: &Realm,
433    tenant: Option<crate::TenantId>,
434    event: &str,
435    actor: &dyn PanelUser,
436    target_id: &str,
437    target_name: Option<&str>,
438    until: Option<Timestamp>,
439) -> topcoat::Result<()> {
440    if !panel.audit_log {
441        return Ok(());
442    }
443    let mut entry = AuditEvent::new(event)
444        .key(target_id.to_owned(), None)
445        .change(
446            "user",
447            crate::t!(cx, "tablo-impersonation-user"),
448            None,
449            Some(target_name.unwrap_or(target_id).to_owned()),
450        );
451    // A session that runs out unseen records no stop, so a start names when it ends at the
452    // latest.
453    if let Some(until) = until {
454        entry = entry.change(
455            "until",
456            crate::t!(cx, "tablo-impersonation-until"),
457            None,
458            Some(until.to_string()),
459        );
460    }
461    entry
462        .write(
463            ex,
464            panel.prefix.clone(),
465            tenant,
466            Some((actor.user_id(), actor.display_name().to_owned())),
467        )
468        .await
469        .map_err(|error| {
470            crate::error::driver_failure(error, "The audit log is unavailable right now.")
471        })?;
472    Ok(())
473}
474
475/// Whether the request reaches a page the actor may not use while acting as another user: one
476/// that changes the target's credentials, address, factors or tenants.
477pub(super) fn refused(cx: &Cx, panel: &Realm) -> bool {
478    let path = uri(cx).path();
479    let prefix = &panel.prefix;
480    [
481        panel.pages.profile.clone(),
482        panel.pages.two_factor.clone(),
483        panel.pages.tenant_profile.clone(),
484        format!("{prefix}/register-tenant"),
485        format!("{prefix}/verify-email"),
486    ]
487    .iter()
488    .any(|root| {
489        path.strip_prefix(root.as_str())
490            .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
491    })
492}