use std::{
any::Any,
sync::Arc,
time::{Duration, SystemTime},
};
use jiff::Timestamp;
use topcoat::{
context::Cx,
icon::icon,
router::{
Body, RouteFuture,
error::{forbidden, see_other},
request::uri,
response::IntoResponse,
},
view::{BoxView, ViewExt, attributes, view},
};
use super::{
Auth, AuthConfig, DynAuthenticator, PanelUser, SignedIn, infrastructure_failure, panel_root,
realm::{Realm, panel_prefix},
session::{self, AuthSession},
signed, unauthenticated_error,
};
use crate::AuditEvent;
pub const IMPERSONATE_FIELD: &str = "user";
type Rule<U> = dyn Fn(&Cx, &U, &U) -> bool + Send + Sync;
pub struct Impersonation<U> {
allows: Arc<Rule<U>>,
lifetime: Duration,
across_tenants: bool,
}
const LONGEST: Duration = Duration::from_hours(24);
impl<U: PanelUser> Impersonation<U> {
pub fn new(allows: impl Fn(&Cx, &U, &U) -> bool + Send + Sync + 'static) -> Self {
Self {
allows: Arc::new(allows),
lifetime: Duration::from_hours(1),
across_tenants: false,
}
}
#[must_use]
pub fn lifetime(mut self, lifetime: Duration) -> Self {
self.lifetime = lifetime.min(LONGEST);
self
}
#[must_use]
pub fn across_tenants(mut self) -> Self {
self.across_tenants = true;
self
}
}
impl<U: PanelUser> Auth<U> {
#[must_use]
pub fn impersonation(mut self, impersonation: Impersonation<U>) -> Self {
let allows = impersonation.allows;
self.config.impersonation = Some(ImpersonationConfig {
allows: Arc::new(move |cx, actor, target| {
let (actor, target): (&dyn Any, &dyn Any) = (actor, target);
match (actor.downcast_ref::<U>(), target.downcast_ref::<U>()) {
(Some(actor), Some(target)) => allows(cx, actor, target),
_ => false,
}
}),
lifetime: impersonation.lifetime,
across_tenants: impersonation.across_tenants,
});
self
}
}
type Allows = dyn Fn(&Cx, &dyn PanelUser, &dyn PanelUser) -> bool + Send + Sync;
#[derive(Clone)]
pub(crate) struct ImpersonationConfig {
allows: Arc<Allows>,
lifetime: Duration,
across_tenants: bool,
}
impl std::fmt::Debug for ImpersonationConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Impersonation")
.field("lifetime", &self.lifetime)
.field("across_tenants", &self.across_tenants)
.finish_non_exhaustive()
}
}
impl ImpersonationConfig {
fn permits(
&self,
cx: &Cx,
panel: &Realm,
actor: &dyn PanelUser,
target: &dyn PanelUser,
) -> bool {
let within_tenants = !panel.serves_tenants
|| self.across_tenants
|| target.tenants().iter().all(|membership| {
actor
.tenants()
.iter()
.any(|own| own.tenant == membership.tenant)
});
actor.user_id() != target.user_id()
&& actor.can_access_panel(&panel.prefix)
&& target.can_access_panel(&panel.prefix)
&& within_tenants
&& (self.allows)(cx, actor, target)
}
}
impl AuthConfig {
pub(crate) fn impersonation(&self) -> Option<&ImpersonationConfig> {
self.authenticator().and(self.impersonation.as_ref())
}
}
pub fn impersonator<U: PanelUser>(cx: &Cx) -> Option<&U> {
let actor: &dyn Any = &**signed(cx)?.impersonator.as_ref()?;
actor.downcast_ref()
}
pub(super) async fn resolve(
cx: &Cx,
row: AuthSession,
actor: Arc<dyn PanelUser>,
panel: &Arc<Realm>,
authenticator: &dyn DynAuthenticator,
) -> topcoat::Result<SignedIn> {
if let Some(target) = live_target(cx, &row, &*actor, panel, authenticator).await? {
return Ok(SignedIn {
user: target,
realm: Arc::clone(panel),
tenant: row.tenant,
impersonator: Some(actor),
});
}
let mut db = crate::db::db(cx)?;
let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
session::set_impersonation(&mut tx, &row.token_hash, None).await?;
if let Some(target) = row.impersonating.as_deref() {
let tenant = row.tenant;
record(
cx,
&mut tx,
panel,
tenant,
"stop_impersonating",
&*actor,
target,
None,
)
.await?;
}
tx.commit().await.map_err(infrastructure_failure)?;
Ok(SignedIn {
user: actor,
realm: Arc::clone(panel),
tenant: None,
impersonator: None,
})
}
async fn live_target(
cx: &Cx,
row: &AuthSession,
actor: &dyn PanelUser,
panel: &Realm,
authenticator: &dyn DynAuthenticator,
) -> topcoat::Result<Option<Arc<dyn PanelUser>>> {
let (Some(id), Some(until), Some(config)) = (
row.impersonating.as_deref(),
row.impersonating_until,
panel.auth.impersonation(),
) else {
return Ok(None);
};
if until <= Timestamp::now() || super::gate::owes_step(cx, panel, actor).await? {
return Ok(None);
}
let target = authenticator
.find_by_id(cx, id)
.await
.map_err(infrastructure_failure)?;
Ok(target.filter(|target| config.permits(cx, panel, actor, &**target)))
}
fn actor(cx: &Cx) -> Option<(&SignedIn, &ImpersonationConfig)> {
let signed = signed(cx).filter(|signed| signed.impersonator.is_none())?;
Some((signed, signed.realm.auth.impersonation()?))
}
pub fn impersonate_button<'a>(cx: &'a Cx, target: &dyn PanelUser) -> BoxView<'a> {
if !may_impersonate(cx, target) {
return view! {}.boxed();
}
let token = crate::csrf::ensure_token(cx);
let action = format!("{}/impersonate", panel_prefix(cx));
let id = target.user_id();
view! {
<form method="post" action=(action) data-impersonate=(id.clone())>
(crate::csrf::field(cx, &token))
<input type="hidden" name=(IMPERSONATE_FIELD) value=(id)>
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Outline,
size: tablo_ui::ButtonSize::Sm,
attrs: attributes! { type="submit" },
icon(data: tablo_ui::icons::EYE)
(crate::t!(cx, "tablo-impersonation-start"))
)
</form>
}
.boxed()
}
pub(crate) fn may_impersonate(cx: &Cx, target: &dyn PanelUser) -> bool {
actor(cx)
.is_some_and(|(signed, config)| config.permits(cx, &signed.realm, &*signed.user, target))
}
pub(crate) async fn start(
cx: &Cx,
ex: &mut dyn toasty::Executor,
target: &dyn PanelUser,
) -> topcoat::Result<()> {
let Some((signed, config)) = actor(cx) else {
return Err(forbidden().into());
};
if !config.permits(cx, &signed.realm, &*signed.user, target) {
return Err(forbidden().into());
}
let until =
Timestamp::try_from(SystemTime::now() + config.lifetime).map_err(topcoat::Error::from)?;
session::impersonate(cx, &mut *ex, Some((target.user_id(), until))).await?;
let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
let (id, name) = (target.user_id(), Some(target.display_name()));
record(
cx,
ex,
realm,
tenant,
"impersonate",
&*signed.user,
&id,
name,
)
.await?;
crate::bus::bus(cx).wake(&signed.realm.prefix, &signed.user.user_id());
Ok(())
}
pub(super) fn start_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let Some(signed) = signed(cx) else {
return Err(unauthenticated_error(cx));
};
let parts = super::input::read_form(cx, body).await?;
crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
let authenticator = signed.realm.auth.authenticator().ok_or_else(forbidden)?;
let id = parts.last(IMPERSONATE_FIELD).ok_or_else(forbidden)?;
let target = authenticator
.find_by_id(cx, id)
.await
.map_err(infrastructure_failure)?
.ok_or_else(forbidden)?;
let mut db = crate::db::db(cx)?;
let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
start(cx, &mut tx, &*target).await?;
tx.commit().await.map_err(infrastructure_failure)?;
see_other(panel_root(cx)).into_response(cx)
})
}
pub(super) fn stop_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let Some(signed) = signed(cx) else {
return Err(unauthenticated_error(cx));
};
let parts = super::input::read_form(cx, body).await?;
crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
if let Some(actor) = &signed.impersonator {
let mut db = crate::db::db(cx)?;
let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
session::impersonate(cx, &mut tx, None).await?;
let (realm, tenant) = (&signed.realm, crate::tenant_id(cx));
let (id, name) = (signed.user.user_id(), Some(signed.user.display_name()));
let event = "stop_impersonating";
record(cx, &mut tx, realm, tenant, event, &**actor, &id, name).await?;
tx.commit().await.map_err(infrastructure_failure)?;
crate::bus::bus(cx).wake(&signed.realm.prefix, &actor.user_id());
}
see_other(panel_root(cx)).into_response(cx)
})
}
pub(crate) fn stop_url(cx: &Cx) -> String {
format!("{}/impersonate/stop", panel_prefix(cx))
}
#[expect(
clippy::too_many_arguments,
reason = "one call per event, each naming every part"
)]
async fn record(
cx: &Cx,
ex: &mut dyn toasty::Executor,
panel: &Realm,
tenant: Option<crate::TenantId>,
event: &str,
actor: &dyn PanelUser,
target_id: &str,
target_name: Option<&str>,
) -> topcoat::Result<()> {
if !panel.audit_log {
return Ok(());
}
AuditEvent::new(event)
.key(target_id.to_owned(), None)
.change(
"user",
crate::t!(cx, "tablo-impersonation-user"),
None,
Some(target_name.unwrap_or(target_id).to_owned()),
)
.write(
ex,
panel.prefix.clone(),
tenant,
Some((actor.user_id(), actor.display_name().to_owned())),
)
.await
.map_err(|error| {
crate::error::driver_failure(error, "The audit log is unavailable right now.")
})?;
Ok(())
}
pub(super) fn refused(cx: &Cx, panel: &Realm) -> bool {
let path = uri(cx).path();
let prefix = &panel.prefix;
[
panel.pages.profile.clone(),
panel.pages.two_factor.clone(),
panel.pages.tenant_profile.clone(),
format!("{prefix}/register-tenant"),
format!("{prefix}/verify-email"),
]
.iter()
.any(|root| {
path.strip_prefix(root.as_str())
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
})
}