autumn_admin_plugin/impersonation.rs
1//! Admin-side wiring for user impersonation (issue #1394).
2//!
3//! The security-critical half lives in
4//! [`autumn_web::auth::impersonation`]; this module is the UI: an extractor
5//! that resolves the banner for the current request, the begin/revert routes,
6//! and the helper an application calls to embed the banner in its **own**
7//! layout.
8//!
9//! # Wiring
10//!
11//! ```rust,ignore
12//! use autumn_admin_plugin::AdminPlugin;
13//! use autumn_web::auth::impersonation::ImpersonationGate;
14//!
15//! autumn_web::app()
16//! .plugin(
17//! AdminPlugin::new()
18//! .register(UserAdmin::default())
19//! // Opt in: only the `admin` role may impersonate. Without this
20//! // call the routes are not mounted and the core primitive
21//! // default-denies.
22//! .with_impersonation(ImpersonationGate::allow_roles(["admin"])),
23//! )
24//! .run()
25//! .await;
26//! ```
27//!
28//! That mounts `POST {prefix}/impersonate` (begin, behind the admin role gate)
29//! and `POST {prefix}/impersonate/stop` (revert, deliberately **outside** the
30//! gate so an operator impersonating a non-admin can always get back).
31//!
32//! Then render the banner in your application layout so it is visible on the
33//! pages the operator is actually looking at:
34//!
35//! ```rust,ignore
36//! #[get("/")]
37//! async fn home(State(state): State<AppState>, session: Session, csrf: CsrfToken) -> Markup {
38//! let banner = autumn_admin_plugin::impersonation_banner_for(
39//! &state, &session, "/admin", csrf.token(), "_csrf",
40//! ).await;
41//! html! {
42//! body {
43//! @if let Some(banner) = banner { (banner) }
44//! main { "…" }
45//! }
46//! }
47//! }
48//! ```
49//!
50//! Add [`IMPERSONATION_BANNER_CSS`](crate::IMPERSONATION_BANNER_CSS) to that
51//! layout's stylesheet; the plugin's own pages already include it.
52
53use autumn_web::auth::impersonation;
54use autumn_web::consent::safe_redirect_target;
55use autumn_web::session::Session;
56use autumn_web::{AppState, AutumnResult};
57use axum::extract::{FromRequestParts, State};
58use axum::http::request::Parts;
59use axum::response::{IntoResponse, Redirect, Response};
60use maud::Markup;
61use serde::Deserialize;
62
63use crate::routes::AdminPrefix;
64use crate::templates::{ImpersonationBanner, impersonation_banner};
65
66/// Request extractor resolving the impersonation banner for the current
67/// session, or `None` when the session is not impersonating.
68///
69/// Infallible: a request with no `SessionLayer` (or no active impersonation)
70/// simply yields `None`, so every admin page can take it unconditionally.
71#[derive(Debug, Clone, Default)]
72pub struct AdminImpersonation(impersonation::Impersonation);
73
74impl AdminImpersonation {
75 /// The active impersonation, if any.
76 #[must_use]
77 pub const fn state(&self) -> Option<&impersonation::ImpersonationState> {
78 self.0.state()
79 }
80
81 /// Build the banner view-model for a page rendered under `prefix`.
82 #[must_use]
83 pub fn banner(
84 &self,
85 admin_prefix: &str,
86 csrf_token: &str,
87 csrf_form_field: &str,
88 ) -> Option<ImpersonationBanner> {
89 self.0
90 .state()
91 .map(|state| ImpersonationBanner::new(state, admin_prefix, csrf_token, csrf_form_field))
92 }
93}
94
95impl FromRequestParts<AppState> for AdminImpersonation {
96 type Rejection = std::convert::Infallible;
97
98 async fn from_request_parts(
99 parts: &mut Parts,
100 state: &AppState,
101 ) -> Result<Self, Self::Rejection> {
102 let inner = impersonation::Impersonation::from_request_parts(parts, state).await?;
103 Ok(Self(inner))
104 }
105}
106
107/// Render the impersonation banner for the current request, or `None` when the
108/// session is not impersonating.
109///
110/// The one call an application makes to put the banner in its own layout.
111/// `admin_prefix` is where the plugin is mounted (default `/admin`); the
112/// "Stop impersonating" form posts to `{admin_prefix}/impersonate/stop`.
113/// Pass an empty `csrf_token` when no `CsrfLayer` is installed — the hidden
114/// field is then omitted rather than rendered blank.
115pub async fn impersonation_banner_for(
116 state: &AppState,
117 session: &Session,
118 admin_prefix: &str,
119 csrf_token: &str,
120 csrf_form_field: &str,
121) -> Option<Markup> {
122 let active = impersonation::impersonation_state(state, session).await?;
123 Some(impersonation_banner(&ImpersonationBanner::new(
124 &active,
125 admin_prefix,
126 csrf_token,
127 csrf_form_field,
128 )))
129}
130
131/// Form body for `POST {prefix}/impersonate`.
132///
133/// Deliberately carries **only** the target user id. The impersonated session's
134/// role is resolved server-side by
135/// [`ImpersonationPolicy::target_role`](autumn_web::auth::impersonation::ImpersonationPolicy::target_role);
136/// accepting it from the request would let an operator mint a session more
137/// privileged than the target really is.
138#[derive(Debug, Deserialize)]
139pub struct BeginForm {
140 user_id: String,
141 /// Where to send the browser afterwards. Validated as a same-origin
142 /// relative path, so it can never become an open redirect.
143 #[serde(default)]
144 return_to: Option<String>,
145}
146
147/// `POST {prefix}/impersonate` — begin impersonating a user.
148///
149/// Mounted **inside** the admin role gate (and the step-up guard, when
150/// enabled), then gated again by the app's
151/// [`ImpersonationGate`](autumn_web::auth::impersonation::ImpersonationGate):
152/// role membership alone is never sufficient.
153pub async fn impersonate_begin(
154 State(state): State<AppState>,
155 session: Session,
156 axum::extract::Form(form): axum::extract::Form<BeginForm>,
157) -> AutumnResult<Response> {
158 impersonation::begin_impersonation(&state, &session, form.user_id).await?;
159 let target = form.return_to.as_deref().unwrap_or("/");
160 Ok(Redirect::to(safe_redirect_target(target)).into_response())
161}
162
163/// `POST {prefix}/impersonate/stop` — revert to the original admin session.
164///
165/// Mounted **outside** the role gate on purpose: while impersonating, the
166/// session no longer carries the admin role, so a gated revert would trap the
167/// operator in the target's identity. It is self-gating — a session that is not
168/// impersonating gets a `400` and nothing changes.
169pub async fn impersonate_stop(
170 State(state): State<AppState>,
171 session: Session,
172 axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
173 body: axum::body::Bytes,
174) -> AutumnResult<Response> {
175 impersonation::end_impersonation(&state, &session).await?;
176 // The banner posts the page it was rendered on, so reverting from an
177 // application page returns there instead of dumping the operator into the
178 // admin panel. Parsed straight from the body rather than through `Form<T>`
179 // so a bare, body-less `POST` still reverts instead of being rejected for a
180 // missing content type — the revert must never be the thing that fails.
181 // Absent or unsafe values fall back to the admin prefix, and
182 // `safe_redirect_target` makes an open redirect impossible either way.
183 let return_to = form_urlencoded::parse(&body)
184 .find(|(key, _)| key == "return_to")
185 .map(|(_, value)| value.into_owned())
186 .unwrap_or_default();
187 let destination = if return_to.is_empty() {
188 safe_redirect_target(&prefix).to_owned()
189 } else {
190 safe_redirect_target(&return_to).to_owned()
191 };
192 Ok(Redirect::to(&destination).into_response())
193}