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.
115    ///
116    /// The session row stores the target in two columns, see [`AuthSession`]. Mounting refuses it
117    /// behind [`Auth::external`].
118    #[must_use]
119    pub fn impersonation(mut self, impersonation: Impersonation<U>) -> Self {
120        let allows = impersonation.allows;
121        self.config.impersonation = Some(ImpersonationConfig {
122            allows: Arc::new(move |cx, actor, target| {
123                let (actor, target): (&dyn Any, &dyn Any) = (actor, target);
124                match (actor.downcast_ref::<U>(), target.downcast_ref::<U>()) {
125                    (Some(actor), Some(target)) => allows(cx, actor, target),
126                    _ => false,
127                }
128            }),
129            lifetime: impersonation.lifetime,
130            across_tenants: impersonation.across_tenants,
131        });
132        self
133    }
134}
135
136type Allows = dyn Fn(&Cx, &dyn PanelUser, &dyn PanelUser) -> bool + Send + Sync;
137
138/// An [`Impersonation`] with its user type erased.
139#[derive(Clone)]
140pub(crate) struct ImpersonationConfig {
141    allows: Arc<Allows>,
142    lifetime: Duration,
143    across_tenants: bool,
144}
145
146impl std::fmt::Debug for ImpersonationConfig {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        f.debug_struct("Impersonation")
149            .field("lifetime", &self.lifetime)
150            .field("across_tenants", &self.across_tenants)
151            .finish_non_exhaustive()
152    }
153}
154
155impl ImpersonationConfig {
156    /// Whether `actor` may act as `target` on `panel`: two users, both admitted to it, the target
157    /// in no tenant the actor is not, on a panel that acts for tenants, and the app's rule
158    /// agreeing.
159    fn permits(
160        &self,
161        cx: &Cx,
162        panel: &Realm,
163        actor: &dyn PanelUser,
164        target: &dyn PanelUser,
165    ) -> bool {
166        let within_tenants = !panel.serves_tenants
167            || self.across_tenants
168            || target.tenants().iter().all(|membership| {
169                actor
170                    .tenants()
171                    .iter()
172                    .any(|own| own.tenant == membership.tenant)
173            });
174        actor.user_id() != target.user_id()
175            && actor.can_access_panel(&panel.prefix)
176            && target.can_access_panel(&panel.prefix)
177            && within_tenants
178            && (self.allows)(cx, actor, target)
179    }
180}
181
182impl AuthConfig {
183    /// The impersonation rule, or `None` when no user may act as another.
184    pub(crate) fn impersonation(&self) -> Option<&ImpersonationConfig> {
185        self.authenticator().and(self.impersonation.as_ref())
186    }
187}
188
189/// The user acting as the signed-in user, as `U`; `None` while the signed-in user acts as
190/// themselves, or when the actor is not a `U`.
191pub fn impersonator<U: PanelUser>(cx: &Cx) -> Option<&U> {
192    let actor: &dyn Any = &**signed(cx)?.impersonator.as_ref()?;
193    actor.downcast_ref()
194}
195
196/// Resolves a session whose `actor` acts as another user: the target, with the actor beside, while
197/// the lifetime lasts and the rule still allows it; else the actor, the row cleared.
198pub(super) async fn resolve(
199    cx: &Cx,
200    row: AuthSession,
201    actor: Arc<dyn PanelUser>,
202    panel: &Arc<Realm>,
203    authenticator: &dyn DynAuthenticator,
204) -> topcoat::Result<SignedIn> {
205    if let Some(target) = live_target(cx, &row, &*actor, panel, authenticator).await? {
206        return Ok(SignedIn {
207            user: target,
208            realm: Arc::clone(panel),
209            tenant: row.tenant,
210            impersonator: Some(actor),
211        });
212    }
213    // Ended by its lifetime, the rule or either user: cleared and recorded together.
214    let mut db = crate::db::db(cx)?;
215    let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
216    session::set_impersonation(&mut tx, &row.token_hash, None).await?;
217    if let Some(target) = row.impersonating.as_deref() {
218        let tenant = row.tenant;
219        record(
220            cx,
221            &mut tx,
222            panel,
223            tenant,
224            "stop_impersonating",
225            &*actor,
226            target,
227            None,
228        )
229        .await?;
230    }
231    tx.commit().await.map_err(infrastructure_failure)?;
232    Ok(SignedIn {
233        user: actor,
234        realm: Arc::clone(panel),
235        tenant: None,
236        impersonator: None,
237    })
238}
239
240async fn live_target(
241    cx: &Cx,
242    row: &AuthSession,
243    actor: &dyn PanelUser,
244    panel: &Realm,
245    authenticator: &dyn DynAuthenticator,
246) -> topcoat::Result<Option<Arc<dyn PanelUser>>> {
247    let (Some(id), Some(until), Some(config)) = (
248        row.impersonating.as_deref(),
249        row.impersonating_until,
250        panel.auth.impersonation(),
251    ) else {
252        return Ok(None);
253    };
254    // An actor who owes a step, such as setting up a factor the panel now requires, goes to it.
255    if until <= Timestamp::now() || super::gate::owes_step(cx, panel, actor).await? {
256        return Ok(None);
257    }
258    let target = authenticator
259        .find_by_id(cx, id)
260        .await
261        .map_err(infrastructure_failure)?;
262    Ok(target.filter(|target| config.permits(cx, panel, actor, &**target)))
263}
264
265/// The signed-in user who may start acting as another, with the panel's rule: one acting as
266/// themselves, on a panel with [`Auth::impersonation`].
267fn actor(cx: &Cx) -> Option<(&SignedIn, &ImpersonationConfig)> {
268    let signed = signed(cx).filter(|signed| signed.impersonator.is_none())?;
269    Some((signed, signed.realm.auth.impersonation()?))
270}
271
272/// A button that acts as `target`, for a page of the app's own, such as a staff list over a user
273/// type that is no model, where the [`Impersonate`](crate::Impersonate) action cannot go: a form
274/// that posts `target`'s [`user_id`](PanelUser::user_id) to `{prefix}/impersonate` with the CSRF
275/// field.
276///
277/// It renders nothing unless the signed-in user may act as `target` under the panel's
278/// [`Impersonation`] rule, as the action's button shows only then; the route asks the rule again.
279///
280/// ```rust
281/// # use tablo_core::{PanelUser, auth};
282/// # use topcoat::{context::Cx, view::{View, view}};
283/// fn act_as<'a>(cx: &'a Cx, colleague: &dyn PanelUser) -> impl View + 'a {
284///     let button = auth::impersonate_button(cx, colleague);
285///     view! { <td>(button)</td> }
286/// }
287/// ```
288pub fn impersonate_button<'a>(cx: &'a Cx, target: &dyn PanelUser) -> BoxView<'a> {
289    if !may_impersonate(cx, target) {
290        return view! {}.boxed();
291    }
292    let token = crate::csrf::ensure_token(cx);
293    let action = format!("{}/impersonate", panel_prefix(cx));
294    let id = target.user_id();
295    view! {
296        <form method="post" action=(action) data-impersonate=(id.clone())>
297            (crate::csrf::field(cx, &token))
298            <input type="hidden" name=(IMPERSONATE_FIELD) value=(id)>
299            tablo_ui::button(
300                variant: tablo_ui::ButtonVariant::Outline,
301                size: tablo_ui::ButtonSize::Sm,
302                attrs: attributes! { type="submit" },
303                icon(data: tablo_ui::icons::EYE)
304                (crate::t!(cx, "tablo-impersonation-start"))
305            )
306        </form>
307    }
308    .boxed()
309}
310
311/// Whether the signed-in user may act as `target`: what shows the
312/// [`Impersonate`](crate::Impersonate) button.
313pub(crate) fn may_impersonate(cx: &Cx, target: &dyn PanelUser) -> bool {
314    actor(cx)
315        .is_some_and(|(signed, config)| config.permits(cx, &signed.realm, &*signed.user, target))
316}
317
318/// Makes the request's session act as `target` through `ex`, and records it through `ex` too, so
319/// a transaction commits both or neither; 403 when the signed-in user may not.
320pub(crate) async fn start(
321    cx: &Cx,
322    ex: &mut dyn toasty::Executor,
323    target: &dyn PanelUser,
324) -> topcoat::Result<()> {
325    let Some((signed, config)) = actor(cx) else {
326        return Err(forbidden().into());
327    };
328    if !config.permits(cx, &signed.realm, &*signed.user, target) {
329        return Err(forbidden().into());
330    }
331    let until =
332        Timestamp::try_from(SystemTime::now() + config.lifetime).map_err(topcoat::Error::from)?;
333    session::impersonate(cx, &mut *ex, Some((target.user_id(), until))).await?;
334    let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
335    let (id, name) = (target.user_id(), Some(target.display_name()));
336    record(
337        cx,
338        ex,
339        realm,
340        tenant,
341        "impersonate",
342        &*signed.user,
343        &id,
344        name,
345    )
346    .await?;
347    crate::bus::bus(cx).wake(&signed.realm.prefix, &signed.user.user_id());
348    Ok(())
349}
350
351/// `POST {prefix}/impersonate`: act as the user [`IMPERSONATE_FIELD`] names, then land on the
352/// panel root.
353pub(super) fn start_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
354    Box::pin(async move {
355        let Some(signed) = signed(cx) else {
356            return Err(unauthenticated_error(cx));
357        };
358        let parts = super::input::read_form(cx, body).await?;
359        crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
360        let authenticator = signed.realm.auth.authenticator().ok_or_else(forbidden)?;
361        let id = parts.last(IMPERSONATE_FIELD).ok_or_else(forbidden)?;
362        // An unknown id answers as a refused one does, so the route tells no one who exists.
363        let target = authenticator
364            .find_by_id(cx, id)
365            .await
366            .map_err(infrastructure_failure)?
367            .ok_or_else(forbidden)?;
368        let mut db = crate::db::db(cx)?;
369        let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
370        start(cx, &mut tx, &*target).await?;
371        tx.commit().await.map_err(infrastructure_failure)?;
372        see_other(panel_root(cx)).into_response(cx)
373    })
374}
375
376/// `POST {prefix}/impersonate/stop`: the actor is themselves again, on the panel root.
377pub(super) fn stop_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
378    Box::pin(async move {
379        let Some(signed) = signed(cx) else {
380            return Err(unauthenticated_error(cx));
381        };
382        let parts = super::input::read_form(cx, body).await?;
383        crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
384        if let Some(actor) = &signed.impersonator {
385            let mut db = crate::db::db(cx)?;
386            let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
387            session::impersonate(cx, &mut tx, None).await?;
388            let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
389            let (id, name) = (signed.user.user_id(), Some(signed.user.display_name()));
390            let event = "stop_impersonating";
391            record(cx, &mut tx, realm, tenant, event, &**actor, &id, name).await?;
392            tx.commit().await.map_err(infrastructure_failure)?;
393            crate::bus::bus(cx).wake(&signed.realm.prefix, &actor.user_id());
394        }
395        see_other(panel_root(cx)).into_response(cx)
396    })
397}
398
399/// Where the banner's Stop button posts: `{prefix}/impersonate/stop`.
400pub(crate) fn stop_url(cx: &Cx) -> String {
401    format!("{}/impersonate/stop", panel_prefix(cx))
402}
403
404/// Records `event`, done by `actor` to the user of `target_id`, named `target_name` when known, in
405/// `panel`'s audit log for `tenant`, when it keeps one.
406#[expect(
407    clippy::too_many_arguments,
408    reason = "one call per event, each naming every part"
409)]
410async fn record(
411    cx: &Cx,
412    ex: &mut dyn toasty::Executor,
413    panel: &Realm,
414    tenant: Option<crate::TenantId>,
415    event: &str,
416    actor: &dyn PanelUser,
417    target_id: &str,
418    target_name: Option<&str>,
419) -> topcoat::Result<()> {
420    if !panel.audit_log {
421        return Ok(());
422    }
423    AuditEvent::new(event)
424        .key(target_id.to_owned(), None)
425        .change(
426            "user",
427            crate::t!(cx, "tablo-impersonation-user"),
428            None,
429            Some(target_name.unwrap_or(target_id).to_owned()),
430        )
431        .write(
432            ex,
433            panel.prefix.clone(),
434            tenant,
435            Some((actor.user_id(), actor.display_name().to_owned())),
436        )
437        .await
438        .map_err(|error| {
439            crate::error::driver_failure(error, "The audit log is unavailable right now.")
440        })?;
441    Ok(())
442}
443
444/// Whether the request reaches a page the actor may not use while acting as another user: one
445/// that changes the target's credentials, address, factors or tenants.
446pub(super) fn refused(cx: &Cx, panel: &Realm) -> bool {
447    let path = uri(cx).path();
448    let prefix = &panel.prefix;
449    [
450        panel.pages.profile.clone(),
451        panel.pages.two_factor.clone(),
452        panel.pages.tenant_profile.clone(),
453        format!("{prefix}/register-tenant"),
454        format!("{prefix}/verify-email"),
455    ]
456    .iter()
457    .any(|root| {
458        path.strip_prefix(root.as_str())
459            .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
460    })
461}