Skip to main content

autumn_web/auth/
impersonation.rs

1//! Admin **user impersonation** — "log in as this user", without losing the
2//! real admin's identity (issue #1394).
3//!
4//! Every support team eventually needs to reproduce a bug, verify a
5//! permission, or walk a customer through a screen *as that customer*. Doing it
6//! by hand — `session.insert("user_id", target)` — silently destroys the audit
7//! trail: from that moment on every version row and audit event claims the
8//! customer did it. This module is the primitive that makes the secure version
9//! the easy one.
10//!
11//! # What it does
12//!
13//! [`begin_impersonation`] swaps the session's **effective** user to the target
14//! and records the real admin separately under [`IMPERSONATOR_SESSION_KEY`].
15//! Because the effective user lives in the ordinary auth session key,
16//! everything the framework resolves "the current user" from — `#[secured]`,
17//! [`RequireAuth`](super::RequireAuth), [`PolicyContext`] — transparently sees
18//! the *impersonated* user, exactly as if they had logged in. ([`Auth<T>`] is
19//! populated by the app's own loader middleware from request extensions, so it
20//! follows only if that loader reads the auth session key.) Meanwhile the ambient
21//! [current actor](crate::current::Current) — the value that seeds
22//! `#[repository(versioned)]` version rows and audit events — is published as
23//! the **real impersonator**, so writes made during the session stay honestly
24//! attributed.
25//!
26//! [`end_impersonation`] reverses it, restoring the admin's user id and role.
27//! Both directions rotate the session id (no fixation) and write an audit
28//! event naming both parties.
29//!
30//! # Default-deny
31//!
32//! Beginning impersonation requires an [`ImpersonationGate`] installed in
33//! [`AppState`]. With no gate registered every attempt is refused with `403` —
34//! an app opts in explicitly:
35//!
36//! ```rust,no_run
37//! use autumn_web::app::AppBuilder;
38//! use autumn_web::auth::impersonation::ImpersonationGate;
39//!
40//! # fn wire(app: AppBuilder) -> AppBuilder {
41//! app.impersonation_gate(ImpersonationGate::allow_roles(["admin"]))
42//! # }
43//! ```
44//!
45//! `autumn-admin-plugin` wraps this in `AdminPlugin::with_impersonation(gate)`,
46//! which registers the gate *and* mounts the begin/revert routes plus the
47//! "Viewing as … — Stop impersonating" banner.
48//!
49//! # An audit sink is required
50//!
51//! [`begin_impersonation`] refuses with `500` unless the app has an
52//! [`AuditLogger`](crate::audit::AuditLogger) with at least one sink installed
53//! — `audit::write_from_state` is a silent no-op without one, and an
54//! unrecorded identity swap is exactly what this module exists to prevent.
55//! `AppBuilder::with_audit_sink(TracingAuditSink)` is the minimum; a durable
56//! sink (`JsonlFileAuditSink`, or your own) is what you actually want. Ending
57//! an impersonation is deliberately *not* subject to this: dropping privilege
58//! must always succeed.
59//!
60//! # Scope
61//!
62//! Session-based auth only: an API-token principal has no session to swap, so
63//! the token authentication path is untouched. Same-tenant only — the
64//! [`ImpersonationPolicy`] is the seam where an app enforces its tenancy
65//! boundary (it receives the full [`PolicyContext`], including the session and
66//! the DB pool) — the framework itself enforces no tenancy, and
67//! [`ImpersonationGate::allow_roles`] in particular does not look at the target
68//! at all, so a multi-tenant app wants a real [`ImpersonationPolicy`].
69//!
70//! Step-up does **not** carry over: [`begin_impersonation`] stashes the
71//! operator's `last_strong_auth_at` claim and drops it from the impersonated
72//! session, so a `#[step_up]` route cannot run a sensitive action on the
73//! target's account on the strength of the *operator's* re-authentication.
74//! [`end_impersonation`] restores the operator's own claim.
75//!
76//! # Extra identity keys
77//!
78//! Only the auth session key swaps by default. If the app stores other
79//! identity values in the session (for example `user_email`), return those
80//! keys from [`ImpersonationPolicy::target_session_values`].
81//! [`begin_impersonation`] swaps them. [`end_impersonation`] restores the
82//! operator's values. [`clear`] removes them and restores nothing.
83//!
84//! # Reserved session keys
85//!
86//! [`RESERVED_SESSION_KEYS`] belong to the framework; do not configure
87//! `[auth].session_key` to collide with them (impersonation refuses outright if
88//! you do, and logs it at startup), and do not write them by hand. Call
89//! [`clear`] from any flow that
90//! replaces the session's user outright (a login, a magic-link or passkey
91//! promotion) so a record left behind by an operator who never reverted is not
92//! inherited by whoever logs in next.
93//!
94//! [`Auth<T>`]: super::Auth
95//!
96//! [`AppState`]: crate::AppState
97//! [`PolicyContext`]: crate::authorization::PolicyContext
98
99use std::sync::Arc;
100
101use crate::AppState;
102use crate::audit::{AuditEvent, AuditStatus};
103use crate::authorization::{BoxFuture, PolicyContext};
104use crate::current::Current;
105use crate::session::Session;
106
107/// Session key holding the **real** admin's id while impersonation is active.
108///
109/// Reserved by the framework: application code must not write it directly —
110/// doing so would forge an impersonation that never passed the
111/// [`ImpersonationGate`] and never produced an audit event. Read it through
112/// [`impersonator_id`] instead.
113pub const IMPERSONATOR_SESSION_KEY: &str = "impersonator_id";
114
115/// Session key recording **which** user the impersonation record describes.
116///
117/// The record is only honored while this still matches the session's effective
118/// user. Without that binding, a stale record left behind by an operator who
119/// never reverted would be picked up by whoever logs in on that session next —
120/// handing them the admin's identity through the revert route, and
121/// misattributing their writes to the admin in the meantime. Reserved by the
122/// framework, like [`IMPERSONATOR_SESSION_KEY`].
123pub const IMPERSONATED_SESSION_KEY: &str = "impersonated_id";
124
125/// Session key stashing the real admin's role for the duration of the
126/// impersonation, so [`end_impersonation`] can restore it exactly. Reserved by
127/// the framework, like [`IMPERSONATOR_SESSION_KEY`].
128pub const IMPERSONATOR_ROLE_SESSION_KEY: &str = "impersonator_role";
129
130/// Session key binding the record to the session generation that created it.
131///
132/// Holds the session id [`begin_impersonation`] minted when it rotated. Any
133/// later rotation — which every login is required to perform, to prevent
134/// fixation — leaves the record pointing at a generation that no longer exists,
135/// so it is treated as stale. Without this, a customer who logs in as
136/// *themselves* on a browser where they had just been impersonated would still
137/// match the recorded target, and the revert route would hand them the
138/// operator's identity. Reserved by the framework, like
139/// [`IMPERSONATOR_SESSION_KEY`].
140pub const IMPERSONATION_SESSION_ID_KEY: &str = "impersonation_session_id";
141
142/// Session key stashing the real admin's step-up claim.
143///
144/// Holds [`step_up::STEP_UP_SESSION_KEY`](crate::step_up::STEP_UP_SESSION_KEY)
145/// for the duration of the impersonation, so the impersonated session cannot
146/// spend the operator's freshness. Reserved by the framework, like
147/// [`IMPERSONATOR_SESSION_KEY`].
148pub const IMPERSONATOR_STEP_UP_SESSION_KEY: &str = "impersonator_last_strong_auth_at";
149
150/// Session key stashing the operator's values for the extra identity keys.
151///
152/// The value is a JSON object. It maps each key to the operator's old value,
153/// or to `null` if the key was unset. [`begin_impersonation`] writes it for
154/// each key that [`ImpersonationPolicy::target_session_values`] returns.
155/// [`end_impersonation`] reads it. Reserved by the framework, like
156/// [`IMPERSONATOR_SESSION_KEY`].
157pub const IMPERSONATOR_IDENTITY_SESSION_KEY: &str = "impersonator_identity_keys";
158
159/// Session key holding the current role. Mirrors the key `#[secured("role")]`
160/// and the admin plugin's role middleware read.
161const ROLE_SESSION_KEY: &str = "role";
162
163/// Every session key the impersonation swap must not collide with.
164///
165/// Most are keys impersonation writes for its own bookkeeping; the live
166/// step-up key is read and stashed by the swap rather than written, but it is
167/// reserved all the same — the swap moves the operator's claim aside and then
168/// writes the target id through the configured auth key, so a colliding
169/// configuration would let a numeric target id satisfy step-up with no
170/// reauthentication.
171/// An app must not configure `[auth].session_key` (or write by hand) any key in
172/// this list: the swap would then clobber its own record.
173/// [`begin_impersonation`] refuses rather than corrupting the session, and
174/// `AppBuilder::impersonation_gate` logs the misconfiguration at startup.
175pub const RESERVED_SESSION_KEYS: [&str; 8] = [
176    IMPERSONATOR_SESSION_KEY,
177    IMPERSONATED_SESSION_KEY,
178    IMPERSONATION_SESSION_ID_KEY,
179    IMPERSONATOR_ROLE_SESSION_KEY,
180    IMPERSONATOR_STEP_UP_SESSION_KEY,
181    IMPERSONATOR_IDENTITY_SESSION_KEY,
182    ROLE_SESSION_KEY,
183    crate::step_up::STEP_UP_SESSION_KEY,
184];
185
186/// Whether `key` is one of the [`RESERVED_SESSION_KEYS`].
187///
188/// Use it to validate a configured `[auth].session_key` at startup, the way the
189/// framework's TOTP-pending keys are validated.
190#[must_use]
191pub fn is_reserved_session_key(key: &str) -> bool {
192    RESERVED_SESSION_KEYS.contains(&key)
193}
194
195/// Audit action recorded when impersonation begins.
196pub const BEGIN_AUDIT_ACTION: &str = "auth.impersonation.begin";
197
198/// Audit action recorded when impersonation ends.
199pub const END_AUDIT_ACTION: &str = "auth.impersonation.end";
200
201// ── Target ───────────────────────────────────────────────────────
202
203/// The user an operator is asking to impersonate.
204///
205/// Constructed from any string-like id; `role` is an **optional, trusted,
206/// server-side** decision about which role the impersonated session should
207/// carry. It must never be populated from request input — that would let an
208/// operator mint a session more privileged than the target really is. Leave it
209/// unset and let [`ImpersonationPolicy::target_role`] resolve it from the app's
210/// own user store (which is what `autumn-admin-plugin`'s route does).
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct ImpersonationTarget {
213    user_id: String,
214    role: Option<String>,
215}
216
217impl ImpersonationTarget {
218    /// A target identified by user id, with no role decided yet.
219    #[must_use]
220    pub fn new(user_id: impl Into<String>) -> Self {
221        Self {
222            user_id: user_id.into(),
223            role: None,
224        }
225    }
226
227    /// Attach the role the impersonated session should carry.
228    ///
229    /// Trusted input only — see the type-level note.
230    #[must_use]
231    pub fn with_role(mut self, role: impl Into<String>) -> Self {
232        self.role = Some(role.into());
233        self
234    }
235
236    /// The target user's id.
237    #[must_use]
238    pub fn user_id(&self) -> &str {
239        &self.user_id
240    }
241
242    /// The explicitly-attached role, if any.
243    #[must_use]
244    pub fn role(&self) -> Option<&str> {
245        self.role.as_deref()
246    }
247
248    /// The same target with its user id trimmed of surrounding whitespace.
249    ///
250    /// [`begin_impersonation`] applies this **before** the
251    /// [`ImpersonationPolicy`] ever sees the target, so the id the policy
252    /// authorizes, the id the audit event records, and the id written to the
253    /// session are always one and the same string.
254    #[must_use]
255    pub fn normalized(mut self) -> Self {
256        self.user_id = self.user_id.trim().to_owned();
257        self
258    }
259}
260
261impl From<String> for ImpersonationTarget {
262    fn from(user_id: String) -> Self {
263        Self::new(user_id)
264    }
265}
266
267impl From<&str> for ImpersonationTarget {
268    fn from(user_id: &str) -> Self {
269        Self::new(user_id)
270    }
271}
272
273impl From<&String> for ImpersonationTarget {
274    fn from(user_id: &String) -> Self {
275        Self::new(user_id)
276    }
277}
278
279// ── Policy + gate ────────────────────────────────────────────────
280
281/// Decides whether the current principal may impersonate a given target.
282///
283/// Default-deny: [`can_impersonate`](Self::can_impersonate) has no default
284/// implementation, and an app with no gate registered refuses every attempt.
285///
286/// ```rust
287/// use autumn_web::authorization::{BoxFuture, PolicyContext};
288/// use autumn_web::auth::impersonation::{ImpersonationPolicy, ImpersonationTarget};
289///
290/// struct SupportDesk;
291///
292/// impl ImpersonationPolicy for SupportDesk {
293///     fn can_impersonate<'a>(
294///         &'a self,
295///         ctx: &'a PolicyContext,
296///         target: &'a ImpersonationTarget,
297///     ) -> BoxFuture<'a, bool> {
298///         // Same-tenant only: consult the app's own store through `ctx`.
299///         Box::pin(async move { ctx.has_role("support") && target.user_id() != "root" })
300///     }
301/// }
302/// ```
303pub trait ImpersonationPolicy: Send + Sync + 'static {
304    /// `true` to allow the impersonation, `false` to refuse it with `403`.
305    fn can_impersonate<'a>(
306        &'a self,
307        ctx: &'a PolicyContext,
308        target: &'a ImpersonationTarget,
309    ) -> BoxFuture<'a, bool>;
310
311    /// The role the impersonated session should carry, resolved **server-side**
312    /// from the app's own user store.
313    ///
314    /// Only consulted when the [`ImpersonationTarget`] carries no explicit
315    /// role. Defaults to `None`, which drops the role entirely for the duration
316    /// of the impersonation — the safe default, since the framework cannot know
317    /// what role the target really holds and inheriting the *admin's* role would
318    /// be a straight privilege escalation.
319    fn target_role<'a>(
320        &'a self,
321        _ctx: &'a PolicyContext,
322        _target: &'a ImpersonationTarget,
323    ) -> BoxFuture<'a, Option<String>> {
324        Box::pin(async { None })
325    }
326
327    /// Extra session keys to swap to the target, as `(key, value)` pairs.
328    /// A `None` value means the target has no value: the key is removed for the
329    /// impersonation, and the operator's value returns on the end.
330    ///
331    /// Use this when the app stores identity in keys other than the auth key,
332    /// for example `user_email`. [`begin_impersonation`] saves the operator's
333    /// value for each key and writes the target's value.
334    /// [`end_impersonation`] restores the operator's values. Resolve the values
335    /// server-side from the app's user store.
336    ///
337    /// Default: no keys. If the policy returns a reserved key (see
338    /// [`RESERVED_SESSION_KEYS`]) or the auth key, [`begin_impersonation`]
339    /// fails with `500`. The policy must not return other keys the framework
340    /// reads, such as the flash, locale or time-zone keys.
341    fn target_session_values<'a>(
342        &'a self,
343        _ctx: &'a PolicyContext,
344        _target: &'a ImpersonationTarget,
345    ) -> BoxFuture<'a, Vec<(String, Option<String>)>> {
346        Box::pin(async { Vec::new() })
347    }
348}
349
350/// Refuses everything. The behavior an app gets when no gate is registered.
351struct DenyAll;
352
353impl ImpersonationPolicy for DenyAll {
354    fn can_impersonate<'a>(
355        &'a self,
356        _ctx: &'a PolicyContext,
357        _target: &'a ImpersonationTarget,
358    ) -> BoxFuture<'a, bool> {
359        Box::pin(async { false })
360    }
361}
362
363/// Allows any principal holding one of the listed roles.
364struct AllowRoles(Vec<String>);
365
366impl ImpersonationPolicy for AllowRoles {
367    fn can_impersonate<'a>(
368        &'a self,
369        ctx: &'a PolicyContext,
370        _target: &'a ImpersonationTarget,
371    ) -> BoxFuture<'a, bool> {
372        Box::pin(async move { ctx.has_any_role(&self.0) })
373    }
374}
375
376/// The installed impersonation authorization gate.
377///
378/// Register one in [`AppState`](crate::AppState) to opt an app into
379/// impersonation; without it [`begin_impersonation`] always returns `403`.
380#[derive(Clone)]
381pub struct ImpersonationGate(Arc<dyn ImpersonationPolicy>);
382
383impl ImpersonationGate {
384    /// A gate that refuses everything — the framework default, spelled out.
385    #[must_use]
386    pub fn deny_all() -> Self {
387        Self(Arc::new(DenyAll))
388    }
389
390    /// Allow any principal whose session role is one of `roles`.
391    ///
392    /// The one-liner for "our `admin` role may impersonate":
393    /// `ImpersonationGate::allow_roles(["admin"])`. The impersonated session
394    /// still receives **no** role (see [`ImpersonationPolicy::target_role`]);
395    /// use [`custom`](Self::custom) to resolve the target's real role.
396    #[must_use]
397    pub fn allow_roles<I, S>(roles: I) -> Self
398    where
399        I: IntoIterator<Item = S>,
400        S: Into<String>,
401    {
402        Self(Arc::new(AllowRoles(
403            roles.into_iter().map(Into::into).collect(),
404        )))
405    }
406
407    /// Install an application-defined [`ImpersonationPolicy`].
408    #[must_use]
409    pub fn custom<P: ImpersonationPolicy>(policy: P) -> Self {
410        Self(Arc::new(policy))
411    }
412
413    /// Borrow the underlying policy.
414    #[must_use]
415    pub fn policy(&self) -> &dyn ImpersonationPolicy {
416        &*self.0
417    }
418}
419
420impl std::fmt::Debug for ImpersonationGate {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        f.write_str("ImpersonationGate")
423    }
424}
425
426// ── State ────────────────────────────────────────────────────────
427
428/// A snapshot of an active impersonation: who is really acting, and as whom.
429#[derive(Debug, Clone, PartialEq, Eq)]
430#[non_exhaustive]
431pub struct ImpersonationState {
432    /// The **real** operator — the id audit and version writes are attributed
433    /// to while the impersonation is active.
434    pub impersonator_id: String,
435    /// The user the session currently resolves as.
436    pub effective_user_id: String,
437}
438
439/// The impersonation record on the session, if it is still live.
440///
441/// Returns `(impersonator_id, impersonated_id)` only when all three reserved
442/// keys are present **and** the record is bound to the current session
443/// generation. Callers must additionally confirm `impersonated_id` matches the
444/// session's effective user — see [`audit_actor_id`], the validating form every
445/// framework seam uses.
446///
447/// The generation check is what makes the record un-inheritable: a login (which
448/// must rotate the session id) leaves the record bound to a generation that no
449/// longer exists, so it stops being honored even if the person logging in is
450/// the very user who was being impersonated.
451async fn live_record(session: &Session) -> Option<(String, String)> {
452    let impersonator = session.get(IMPERSONATOR_SESSION_KEY).await?;
453    let impersonated = session.get(IMPERSONATED_SESSION_KEY).await?;
454    let bound_session_id = session.get(IMPERSONATION_SESSION_ID_KEY).await?;
455    (bound_session_id == session.id().await).then_some((impersonator, impersonated))
456}
457
458/// The real operator behind the current session, or `None` when the session is
459/// not impersonating.
460///
461/// The companion to ordinary current-user resolution: `session.get("user_id")`
462/// (and everything built on it) answers *"who does this request act as?"*, this
463/// answers *"who is really doing it?"*.
464///
465/// Validated: a record whose recorded target no longer matches the session's
466/// effective user is stale — someone logged in on this session after an
467/// operator walked away without reverting — and is reported as "not
468/// impersonating" rather than silently attributing that person's work to the
469/// operator. Clear such a record with [`clear`].
470pub async fn impersonator_id(state: &AppState, session: &Session) -> Option<String> {
471    impersonation_state(state, session)
472        .await
473        .map(|active| active.impersonator_id)
474}
475
476/// Whether the session is currently impersonating someone.
477///
478/// Validated the same way as [`impersonator_id`].
479pub async fn is_impersonating(state: &AppState, session: &Session) -> bool {
480    impersonation_state(state, session).await.is_some()
481}
482
483/// The full [`ImpersonationState`] for the session.
484///
485/// `None` when the session is not impersonating. Reads the effective user
486/// through the app's configured auth session key and verifies the record still
487/// describes that user.
488pub async fn impersonation_state(
489    state: &AppState,
490    session: &Session,
491) -> Option<ImpersonationState> {
492    let effective_user_id = session.get(state.auth_session_key()).await?;
493    let (impersonator_id, impersonated_id) = live_record(session).await?;
494    (impersonated_id == effective_user_id).then_some(ImpersonationState {
495        impersonator_id,
496        effective_user_id,
497    })
498}
499
500/// Drop any impersonation record from the session.
501///
502/// Audits nothing and restores nothing. It removes the extra identity keys that
503/// the impersonation swapped in.
504///
505/// This is **not** the revert — use [`end_impersonation`] for that. It exists
506/// for identity transitions that replace the session's user outright, where the
507/// old record no longer describes anyone: a login, a magic-link or passkey
508/// promotion, an account switch. Call it wherever your app writes the auth
509/// session key directly, so a record left behind by an operator who never
510/// reverted cannot be inherited by the next person to log in on that session.
511///
512/// ```rust,no_run
513/// # use autumn_web::session::Session;
514/// # use autumn_web::auth::impersonation;
515/// async fn establish_session(session: &Session, user_id: &str) {
516///     session.rotate_id().await;
517///     impersonation::clear(session).await;
518///     session.insert("user_id", user_id).await;
519/// }
520/// ```
521pub async fn clear(session: &Session) {
522    session.remove(IMPERSONATOR_SESSION_KEY).await;
523    session.remove(IMPERSONATED_SESSION_KEY).await;
524    session.remove(IMPERSONATION_SESSION_ID_KEY).await;
525    session.remove(IMPERSONATOR_ROLE_SESSION_KEY).await;
526    session.remove(IMPERSONATOR_STEP_UP_SESSION_KEY).await;
527    drop_identity_keys(session).await;
528}
529
530/// Keep a live impersonation across a rotation that did not change who is
531/// signed in.
532///
533/// The record is bound to the session id it was created under, so any later
534/// `rotate_id()` retires it. For a rotation you trust — an idle or periodic
535/// rotation policy, not a login — call this right after `rotate_id()` to move
536/// the binding to the new id. Returns `true` when the record was kept.
537///
538/// It refuses (returns `false`) unless the record was bound to the id just
539/// before the latest rotation and the session still resolves as the
540/// impersonated user. Call it right after each `rotate_id()`. A record already retired by an earlier rotation is never revived.
541///
542/// Do **not** call it from a login flow. A login by the impersonated user
543/// matches the same checks, and the rebind would hand them the record. Login
544/// flows call [`clear`] instead.
545///
546/// ```rust,no_run
547/// # use autumn_web::{session::Session, AppState};
548/// # use autumn_web::auth::impersonation;
549/// async fn rotate_on_idle(state: &AppState, session: &Session) {
550///     session.rotate_id().await;
551///     impersonation::rebind(state, session).await;
552/// }
553/// ```
554pub async fn rebind(state: &AppState, session: &Session) -> bool {
555    let previous_id = session.previous_id().await;
556    let bound = session.get(IMPERSONATION_SESSION_ID_KEY).await;
557    let impersonated = session.get(IMPERSONATED_SESSION_KEY).await;
558    let effective = session.get(state.auth_session_key()).await;
559    let still_live = bound.is_some()
560        && bound == previous_id
561        && session.contains_key(IMPERSONATOR_SESSION_KEY).await
562        && impersonated.is_some()
563        && impersonated == effective;
564    if still_live {
565        session
566            .insert(IMPERSONATION_SESSION_ID_KEY, session.id().await)
567            .await;
568    }
569    still_live
570}
571
572/// The id that audit and version writes made by this session should carry.
573///
574/// Returns the real impersonator while impersonation is active, and
575/// `effective_user_id` otherwise. This is the single rule the framework's
576/// session-based [`Current::set_actor`] seams apply, so
577/// `#[repository(versioned)]` writes stay attributed to the human responsible,
578/// and so do [`AuditEvent`]s — for the audit call sites the framework owns,
579/// which read the acting user from [`Current`].
580///
581/// Hand-rolled audit events do not get this for free: [`AuditEvent::new`]
582/// takes an explicit `actor_id`, so a handler that builds its event from the
583/// effective session user still records the *customer* while impersonation is
584/// active — the exact misattribution impersonation exists to prevent, in the
585/// one place an integrator is most likely to hand-roll. Pass
586/// `audit_actor_id(session, user).await` (or `Current::actor()`) as `actor_id`
587/// there.
588///
589/// Validating by construction: the caller supplies the effective user, so a
590/// stale record — one describing a *different* user than the session now
591/// resolves as — is ignored and the effective user is returned. That keeps a
592/// forgotten impersonation from misattributing the next person's writes to the
593/// operator.
594pub async fn audit_actor_id(session: &Session, effective_user_id: &str) -> String {
595    match live_record(session).await {
596        Some((impersonator, impersonated)) if impersonated == effective_user_id => impersonator,
597        _ => effective_user_id.to_owned(),
598    }
599}
600
601/// The id a per-user throttle should key on for `session`.
602///
603/// The real operator while impersonating, otherwise the effective user. An
604/// operator then spends their own budget and cannot throttle the customer out
605/// of their account. `None` when the session holds no user.
606pub(crate) async fn throttle_principal_id(session: &Session, auth_key: &str) -> Option<String> {
607    let effective = session.get(auth_key).await?;
608    Some(audit_actor_id(session, &effective).await)
609}
610
611// ── Extractor ────────────────────────────────────────────────────
612
613/// Request extractor resolving the session's active impersonation, if any.
614///
615/// Infallible — a request with no session, or one that is not impersonating,
616/// yields `Impersonation(None)` — so a handler can take it unconditionally and
617/// branch on it. The ergonomic form of [`impersonation_state`] for apps that use
618/// the core primitive without `autumn-admin-plugin`.
619///
620/// ```rust,no_run
621/// use autumn_web::prelude::*;
622/// use autumn_web::auth::impersonation::Impersonation;
623///
624/// #[autumn_web::get("/")]
625/// async fn home(impersonation: Impersonation) -> String {
626///     match impersonation.state() {
627///         Some(active) => format!("viewing as {}", active.effective_user_id),
628///         None => "your own account".to_owned(),
629///     }
630/// }
631/// ```
632#[derive(Debug, Clone, Default)]
633pub struct Impersonation(Option<ImpersonationState>);
634
635impl Impersonation {
636    /// The active impersonation, or `None` when the session is not
637    /// impersonating (or there is no session).
638    #[must_use]
639    pub const fn state(&self) -> Option<&ImpersonationState> {
640        self.0.as_ref()
641    }
642
643    /// Consume the extractor, yielding the active impersonation.
644    #[must_use]
645    pub fn into_state(self) -> Option<ImpersonationState> {
646        self.0
647    }
648
649    /// Whether the request is running under an impersonation.
650    #[must_use]
651    pub const fn is_active(&self) -> bool {
652        self.0.is_some()
653    }
654}
655
656impl axum::extract::FromRequestParts<AppState> for Impersonation {
657    type Rejection = std::convert::Infallible;
658
659    async fn from_request_parts(
660        parts: &mut http::request::Parts,
661        state: &AppState,
662    ) -> Result<Self, Self::Rejection> {
663        let Some(session) = parts.extensions.get::<Session>().cloned() else {
664            return Ok(Self(None));
665        };
666        Ok(Self(impersonation_state(state, &session).await))
667    }
668}
669
670// ── Begin / end ──────────────────────────────────────────────────
671
672/// Move `session` from the operator's identity onto the target's.
673///
674/// Only the impersonation-relevant keys move; everything else in the session
675/// (flash, CSRF, wizard progress) is deliberately preserved. The operator's
676/// role and step-up claim are stashed rather than discarded, so
677/// [`end_impersonation`] can restore them exactly.
678async fn swap_identity(
679    session: &Session,
680    auth_key: &str,
681    real_id: &str,
682    target_id: &str,
683    target_role: Option<&str>,
684    extra: &[(String, Option<String>)],
685) {
686    session.insert(IMPERSONATOR_SESSION_KEY, real_id).await;
687    stash_identity_keys(session, extra).await;
688    session.insert(IMPERSONATED_SESSION_KEY, target_id).await;
689    stash(session, ROLE_SESSION_KEY, IMPERSONATOR_ROLE_SESSION_KEY).await;
690    // `last_strong_auth_at` is a bare timestamp with no identity bound to it,
691    // so carrying it across the swap would let a `#[step_up]` route run a
692    // destructive action *on the target's account* on the strength of the
693    // operator's re-authentication — impersonation laundering a credential
694    // check. Every other identity transition in the framework drops this key
695    // for the same reason.
696    stash(
697        session,
698        crate::step_up::STEP_UP_SESSION_KEY,
699        IMPERSONATOR_STEP_UP_SESSION_KEY,
700    )
701    .await;
702
703    session.insert(auth_key, target_id).await;
704    set_or_remove(session, ROLE_SESSION_KEY, target_role).await;
705    // Privilege change ⇒ new session id (no fixation). Bind the record to the
706    // generation this rotation mints, so the next rotation — a login, an
707    // account switch — retires the record instead of letting whoever comes next
708    // inherit it.
709    session.rotate_id().await;
710    session
711        .insert(IMPERSONATION_SESSION_ID_KEY, session.id().await)
712        .await;
713}
714
715/// Save the operator's value for each extra key, then write the target's.
716async fn stash_identity_keys(session: &Session, extra: &[(String, Option<String>)]) {
717    // A stale stash names keys of an old target. Remove them and the stash, so
718    // nothing of the old record shows or returns. Restore nothing.
719    drop_identity_keys(session).await;
720    if extra.is_empty() {
721        return;
722    }
723    let mut saved = serde_json::Map::new();
724    for (key, value) in extra {
725        // Save the operator's value only once. A repeated key must not
726        // overwrite the saved value.
727        if !saved.contains_key(key) {
728            let old = session.get(key).await;
729            saved.insert(key.clone(), old.map_or(serde_json::Value::Null, Into::into));
730        }
731        set_or_remove(session, key, value.as_deref()).await;
732    }
733    session
734        .insert(
735            IMPERSONATOR_IDENTITY_SESSION_KEY,
736            serde_json::Value::Object(saved).to_string(),
737        )
738        .await;
739}
740
741/// Remove the extra keys named in the stash, and the stash. Restores nothing.
742async fn drop_identity_keys(session: &Session) {
743    let Some(raw) = session.remove(IMPERSONATOR_IDENTITY_SESSION_KEY).await else {
744        return;
745    };
746    let Ok(saved) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw) else {
747        return;
748    };
749    for key in saved.keys().filter(|key| !is_reserved_session_key(key)) {
750        session.remove(key).await;
751    }
752}
753
754/// Put back the operator's extra-key values saved by [`stash_identity_keys`].
755async fn restore_identity_keys(session: &Session) {
756    let Some(raw) = session.remove(IMPERSONATOR_IDENTITY_SESSION_KEY).await else {
757        return;
758    };
759    let Ok(saved) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw) else {
760        return;
761    };
762    for (key, value) in saved {
763        // Never write a reserved key from stored data.
764        if is_reserved_session_key(&key) {
765            continue;
766        }
767        set_or_remove(session, &key, value.as_str()).await;
768    }
769}
770
771/// Move `from` to `to`, leaving neither key set when `from` was absent.
772async fn stash(session: &Session, from: &str, to: &str) {
773    let value = session.remove(from).await;
774    set_or_remove(session, to, value.as_deref()).await;
775}
776
777/// Set `key` to `value`, or remove it entirely when `value` is `None`.
778async fn set_or_remove(session: &Session, key: &str, value: Option<&str>) {
779    match value {
780        Some(value) => session.insert(key, value).await,
781        None => {
782            session.remove(key).await;
783        }
784    }
785}
786
787/// Everything [`begin_impersonation`] must establish before it consults the
788/// [`ImpersonationGate`]: a usable auth session key, an authenticated operator,
789/// a non-empty target that is not the operator themselves, and no impersonation
790/// already in flight.
791///
792/// Returns the resolved `(auth_key, real_id)` on success.
793async fn check_begin_preconditions(
794    state: &AppState,
795    session: &Session,
796    target_id: &str,
797) -> crate::AutumnResult<(String, String)> {
798    let auth_key = state.auth_session_key().to_owned();
799    // A configured auth key that collides with one of this module's own keys
800    // makes the swap self-destructive: writing the target through `auth_key`
801    // would overwrite the record it just wrote, so attribution would follow the
802    // target and the revert could not restore the operator. Refuse the
803    // misconfiguration rather than corrupting the session — checked before the
804    // session is read at all, so the error names the real cause.
805    if is_reserved_session_key(&auth_key) {
806        return Err(reserved_auth_key_error(&auth_key));
807    }
808
809    let Some(real_id) = session.get(&auth_key).await else {
810        return Err(crate::AutumnError::unauthorized_msg(
811            "authentication required",
812        ));
813    };
814
815    if target_id.is_empty() {
816        return Err(crate::AutumnError::bad_request_msg(
817            "impersonation target is required",
818        ));
819    }
820
821    // No nesting: an already-impersonated session cannot start a second hop,
822    // so impersonation can never be chained into an escalation. Uses the
823    // validated form, so a *stale* record (one describing a user this session
824    // no longer resolves as) does not permanently wedge the session at 409.
825    if is_impersonating(state, session).await {
826        return Err(crate::AutumnError::conflict_msg(
827            "already impersonating; stop the current impersonation first",
828        ));
829    }
830
831    if target_id == real_id.trim() {
832        return Err(crate::AutumnError::bad_request_msg(
833            "cannot impersonate yourself",
834        ));
835    }
836
837    Ok((auth_key, real_id))
838}
839
840/// The error both directions return when `auth.session_key` collides with a key
841/// the impersonation record reserves.
842fn reserved_auth_key_error(auth_key: &str) -> crate::AutumnError {
843    crate::AutumnError::internal_server_error_msg(format!(
844        "impersonation refused: `auth.session_key` is set to \"{auth_key}\", which is \
845         reserved by the impersonation record; choose another key"
846    ))
847}
848
849/// Begin impersonating `target` from the current admin session.
850///
851/// On success the session's effective user becomes the target, the real admin
852/// is recorded under [`IMPERSONATOR_SESSION_KEY`], the session id is rotated,
853/// and a [`BEGIN_AUDIT_ACTION`] audit event carrying
854/// `{impersonator_id, target_id}` is written.
855///
856/// ```rust,no_run
857/// use autumn_web::prelude::*;
858/// use autumn_web::auth::impersonation;
859///
860/// #[autumn_web::post("/support/impersonate/{user_id}")]
861/// #[autumn_web::secured("support")]
862/// async fn impersonate(
863///     State(state): State<AppState>,
864///     session: Session,
865///     Path(user_id): Path<String>,
866/// ) -> AutumnResult<Redirect> {
867///     impersonation::begin_impersonation(&state, &session, user_id).await?;
868///     Ok(Redirect::to("/"))
869/// }
870/// ```
871///
872/// # Errors
873///
874/// * `400` — the target id is blank, or names the caller themselves.
875/// * `401` — the session is not authenticated.
876/// * `403` — no [`ImpersonationGate`] is installed, or the installed policy
877///   refused. The refusal is itself audited as a failure.
878/// * `409` — the session is **already** impersonating. Impersonation does not
879///   nest, so it cannot be chained to escalate.
880/// * `500` — no audit sink is configured, or the audit event could not be
881///   written. A privileged identity swap that cannot be recorded does not
882///   happen at all, so an app must register an [`AuditSink`](crate::audit::AuditSink)
883///   before enabling impersonation.
884pub async fn begin_impersonation(
885    state: &AppState,
886    session: &Session,
887    target: impl Into<ImpersonationTarget>,
888) -> crate::AutumnResult<ImpersonationState> {
889    // Normalize ONCE, before anything sees the target. The policy, the audit
890    // event, and the session write must all reason about the same string: if the
891    // gate were handed a raw `" root "` while the session received `"root"`, a
892    // policy denying `"root"` would authorize an id it never approved.
893    let target = target.into().normalized();
894    let target_id = target.user_id();
895    let (auth_key, real_id) = check_begin_preconditions(state, session, target_id).await?;
896
897    let ctx = PolicyContext::from_request(state, session).await;
898    let gate = state.extension::<ImpersonationGate>();
899    let allowed = match gate.as_deref() {
900        Some(gate) => gate.policy().can_impersonate(&ctx, &target).await,
901        // Default-deny: an app that never opted in refuses every attempt.
902        None => false,
903    };
904
905    if !allowed {
906        let _ = crate::audit::write_from_state(
907            state,
908            AuditEvent::new(
909                &real_id,
910                BEGIN_AUDIT_ACTION,
911                target_id,
912                None,
913                AuditStatus::Failure,
914            ),
915        )
916        .await;
917        return Err(crate::AutumnError::forbidden_msg(
918            "not permitted to impersonate",
919        ));
920    }
921
922    // Check the extra keys before any write. A bad key then fails the begin
923    // and leaves the session unchanged.
924    let extra = match gate.as_deref() {
925        Some(gate) => gate.policy().target_session_values(&ctx, &target).await,
926        None => Vec::new(),
927    };
928    if let Some((key, _)) = extra
929        .iter()
930        .find(|(key, _)| key == &auth_key || is_reserved_session_key(key))
931    {
932        return Err(crate::AutumnError::internal_server_error_msg(format!(
933            "impersonation refused: `target_session_values` returned the key \"{key}\", \
934             which is the auth key or a reserved key"
935        )));
936    }
937
938    // Fail closed on a *missing* sink, not just a failing one.
939    // `audit::write_from_state` deliberately returns `Ok(())` when no
940    // `AuditLogger` is installed, so without this check an app that enabled
941    // impersonation but never configured a sink would swap identities with no
942    // record at all — precisely the outcome this module exists to prevent.
943    // Checked after the authorization gate so an unauthorized caller still gets
944    // a plain `403` and learns nothing about the app's audit configuration.
945    let audit_enabled = state
946        .extension::<crate::audit::AuditLogger>()
947        .is_some_and(|logger| logger.is_enabled());
948    if !audit_enabled {
949        return Err(crate::AutumnError::internal_server_error_msg(
950            "impersonation refused: no audit sink is configured. Register one \
951             (e.g. `AppBuilder::with_audit_sink(TracingAuditSink)`) before \
952             enabling impersonation — an unrecorded identity swap is exactly \
953             what this feature exists to prevent",
954        ));
955    }
956
957    // Audit *before* the swap, and fail closed: an identity swap that cannot be
958    // recorded must not take effect. The worst case is an over-recorded event,
959    // never a silent one.
960    crate::audit::write_from_state(
961        state,
962        AuditEvent::new(
963            &real_id,
964            BEGIN_AUDIT_ACTION,
965            target_id,
966            None,
967            AuditStatus::Success,
968        ),
969    )
970    .await
971    .map_err(|error| {
972        crate::AutumnError::internal_server_error_msg(format!(
973            "impersonation refused: audit write failed: {error}"
974        ))
975    })?;
976
977    // Resolve the impersonated role server-side only. An explicit role on the
978    // target wins (a trusted, in-process caller decided it); otherwise the
979    // policy resolves it from the app's own user store; otherwise the session
980    // carries no role at all — never the admin's.
981    let target_role = match target.role() {
982        Some(role) => Some(role.to_owned()),
983        None => match gate.as_deref() {
984            Some(gate) => gate.policy().target_role(&ctx, &target).await,
985            None => None,
986        },
987    };
988
989    swap_identity(
990        session,
991        &auth_key,
992        &real_id,
993        target_id,
994        target_role.as_deref(),
995        &extra,
996    )
997    .await;
998
999    // The remainder of *this* request is the impersonator's work too — but only
1000    // seed the actor if nothing stronger already resolved one, matching the
1001    // "first/outermost resolver wins" rule the three session seams follow. On
1002    // every session-authenticated path the actor is already this same
1003    // `real_id` by now (the seam, or the `PolicyContext` built above, seeded
1004    // it), so this changes nothing there; it matters when an API-token bearer
1005    // or an explicit `with_actor(...)` scope resolved the principal first, and
1006    // clobbering that would misattribute the rest of the handler's writes.
1007    if Current::actor().is_none() {
1008        Current::set_actor(real_id.clone());
1009    }
1010
1011    Ok(ImpersonationState {
1012        impersonator_id: real_id,
1013        effective_user_id: target_id.to_owned(),
1014    })
1015}
1016
1017/// End the active impersonation and restore the original admin session.
1018///
1019/// Restores the admin's user id and role, clears the impersonation keys,
1020/// rotates the session id, and writes an [`END_AUDIT_ACTION`] audit event
1021/// carrying `{impersonator_id, target_id}`.
1022///
1023/// Deliberately **not** gated by the [`ImpersonationGate`]: reverting is always
1024/// permitted, so an operator impersonating a user who has lost the granting
1025/// role is never trapped in that identity.
1026///
1027/// # Errors
1028///
1029/// Returns `400` when the session is not impersonating. An audit-sink failure
1030/// is logged but does not block the revert — the opposite trade-off from
1031/// [`begin_impersonation`], because refusing to *drop* privilege would be the
1032/// less safe outcome.
1033pub async fn end_impersonation(
1034    state: &AppState,
1035    session: &Session,
1036) -> crate::AutumnResult<ImpersonationState> {
1037    let auth_key = state.auth_session_key().to_owned();
1038    if is_reserved_session_key(&auth_key) {
1039        return Err(reserved_auth_key_error(&auth_key));
1040    }
1041    let Some(active) = impersonation_state(state, session).await else {
1042        // Either there is no record, or there is a *stale* one describing a user
1043        // this session no longer resolves as — an operator walked away without
1044        // reverting and somebody else logged in on the same session. Honoring
1045        // that record would hand the new user the operator's identity and role
1046        // with no credential, so drop it instead of restoring it.
1047        clear(session).await;
1048        return Err(crate::AutumnError::bad_request_msg("not impersonating"));
1049    };
1050    let real_id = active.impersonator_id;
1051    let effective_user_id = active.effective_user_id;
1052
1053    if let Err(error) = crate::audit::write_from_state(
1054        state,
1055        AuditEvent::new(
1056            &real_id,
1057            END_AUDIT_ACTION,
1058            &effective_user_id,
1059            None,
1060            AuditStatus::Success,
1061        ),
1062    )
1063    .await
1064    {
1065        tracing::error!(
1066            target: "autumn.audit",
1067            impersonator_id = %real_id,
1068            target_id = %effective_user_id,
1069            %error,
1070            "failed to write the impersonation-end audit event; reverting anyway"
1071        );
1072    }
1073
1074    session.insert(&auth_key, real_id.clone()).await;
1075    session.remove(IMPERSONATOR_SESSION_KEY).await;
1076    session.remove(IMPERSONATED_SESSION_KEY).await;
1077    session.remove(IMPERSONATION_SESSION_ID_KEY).await;
1078    restore_identity_keys(session).await;
1079    stash(session, IMPERSONATOR_ROLE_SESSION_KEY, ROLE_SESSION_KEY).await;
1080    // Put the operator's own step-up claim back exactly as it was, and discard
1081    // anything the impersonated session accrued — that freshness belonged to
1082    // the target's credential, not the operator's.
1083    stash(
1084        session,
1085        IMPERSONATOR_STEP_UP_SESSION_KEY,
1086        crate::step_up::STEP_UP_SESSION_KEY,
1087    )
1088    .await;
1089    session.rotate_id().await;
1090
1091    // Same rule as `begin_impersonation`: seed, never clobber.
1092    if Current::actor().is_none() {
1093        Current::set_actor(real_id.clone());
1094    }
1095
1096    Ok(ImpersonationState {
1097        impersonator_id: real_id,
1098        effective_user_id,
1099    })
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105    use std::collections::HashMap;
1106
1107    const TEST_SID: &str = "sid-1";
1108
1109    fn session_with(pairs: &[(&str, &str)]) -> Session {
1110        let data: HashMap<String, String> = pairs
1111            .iter()
1112            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1113            .collect();
1114        Session::new_for_test(TEST_SID.to_owned(), data)
1115    }
1116
1117    /// A session carrying a record bound to the current generation.
1118    fn impersonating_session(impersonator: &str, target: &str) -> Session {
1119        session_with(&[
1120            ("user_id", target),
1121            (IMPERSONATOR_SESSION_KEY, impersonator),
1122            (IMPERSONATED_SESSION_KEY, target),
1123            (IMPERSONATION_SESSION_ID_KEY, TEST_SID),
1124        ])
1125    }
1126
1127    #[tokio::test]
1128    async fn audit_actor_prefers_the_impersonator_and_falls_back_to_the_user() {
1129        let impersonating = impersonating_session("admin", "target");
1130        assert_eq!(audit_actor_id(&impersonating, "target").await, "admin");
1131
1132        let plain = session_with(&[("user_id", "u1")]);
1133        assert_eq!(audit_actor_id(&plain, "u1").await, "u1");
1134    }
1135
1136    #[tokio::test]
1137    async fn a_stale_record_is_ignored_rather_than_misattributed() {
1138        // The record describes `target`, but the session now resolves as
1139        // `carol` — somebody logged in after an operator walked away without
1140        // reverting. Carol's writes are hers.
1141        let stale = session_with(&[
1142            ("user_id", "carol"),
1143            (IMPERSONATOR_SESSION_KEY, "admin"),
1144            (IMPERSONATED_SESSION_KEY, "target"),
1145            (IMPERSONATION_SESSION_ID_KEY, TEST_SID),
1146        ]);
1147        assert_eq!(audit_actor_id(&stale, "carol").await, "carol");
1148    }
1149
1150    #[tokio::test]
1151    async fn a_record_bound_to_another_session_generation_is_ignored() {
1152        // Same user id on both sides, but the session was rotated since — the
1153        // shape a login leaves behind.
1154        let rotated = session_with(&[
1155            ("user_id", "target"),
1156            (IMPERSONATOR_SESSION_KEY, "admin"),
1157            (IMPERSONATED_SESSION_KEY, "target"),
1158            (IMPERSONATION_SESSION_ID_KEY, "a-previous-generation"),
1159        ]);
1160        assert_eq!(audit_actor_id(&rotated, "target").await, "target");
1161    }
1162
1163    #[tokio::test]
1164    async fn a_half_written_record_is_ignored() {
1165        // Only the framework writes both keys together, so a session carrying
1166        // just one (an app poking at the reserved key by hand) is not a record.
1167        let half = session_with(&[("user_id", "target"), (IMPERSONATOR_SESSION_KEY, "admin")]);
1168        assert_eq!(audit_actor_id(&half, "target").await, "target");
1169    }
1170
1171    #[tokio::test]
1172    async fn clear_drops_every_reserved_key() {
1173        let session = session_with(&[
1174            ("user_id", "target"),
1175            (IMPERSONATOR_SESSION_KEY, "admin"),
1176            (IMPERSONATED_SESSION_KEY, "target"),
1177            (IMPERSONATION_SESSION_ID_KEY, TEST_SID),
1178            (IMPERSONATOR_ROLE_SESSION_KEY, "admin"),
1179            (IMPERSONATOR_STEP_UP_SESSION_KEY, "123"),
1180        ]);
1181        clear(&session).await;
1182        for key in [
1183            IMPERSONATOR_SESSION_KEY,
1184            IMPERSONATED_SESSION_KEY,
1185            IMPERSONATION_SESSION_ID_KEY,
1186            IMPERSONATOR_ROLE_SESSION_KEY,
1187            IMPERSONATOR_STEP_UP_SESSION_KEY,
1188        ] {
1189            assert_eq!(session.get(key).await, None, "{key} must be cleared");
1190        }
1191        assert_eq!(
1192            session.get("user_id").await,
1193            Some("target".to_owned()),
1194            "clear() drops the record, not the session"
1195        );
1196    }
1197
1198    #[tokio::test]
1199    async fn the_live_step_up_key_is_reserved() {
1200        // The swap stashes the operator's live step-up claim aside and then
1201        // writes the target id through the configured auth key. With
1202        // `[auth].session_key = "last_strong_auth_at"` those are the same
1203        // entry, and a numeric target id in the neighbourhood of current
1204        // epoch seconds would satisfy step-up with no reauthentication — so
1205        // the reserved-key check refuses the configuration instead.
1206        assert!(is_reserved_session_key(crate::step_up::STEP_UP_SESSION_KEY));
1207        assert_eq!(crate::step_up::STEP_UP_SESSION_KEY, "last_strong_auth_at");
1208        assert_eq!(RESERVED_SESSION_KEYS.len(), 8);
1209    }
1210
1211    #[test]
1212    fn normalizing_a_target_trims_the_id() {
1213        assert_eq!(
1214            ImpersonationTarget::new("  root  ").normalized().user_id(),
1215            "root"
1216        );
1217        assert_eq!(
1218            ImpersonationTarget::new("u1")
1219                .with_role("member")
1220                .normalized()
1221                .role(),
1222            Some("member"),
1223            "normalizing preserves the trusted role decision"
1224        );
1225    }
1226
1227    #[test]
1228    fn a_target_is_built_from_any_string_like_id() {
1229        assert_eq!(ImpersonationTarget::from("u1").user_id(), "u1");
1230        assert_eq!(ImpersonationTarget::from("u1".to_owned()).user_id(), "u1");
1231        assert_eq!(ImpersonationTarget::from(&"u1".to_owned()).user_id(), "u1");
1232        assert_eq!(ImpersonationTarget::new("u1").role(), None);
1233        assert_eq!(
1234            ImpersonationTarget::new("u1").with_role("member").role(),
1235            Some("member")
1236        );
1237    }
1238
1239    #[tokio::test]
1240    async fn deny_all_refuses_and_allow_roles_matches_any_listed_role() {
1241        let session = session_with(&[("user_id", "admin-1"), ("role", "admin")]);
1242        let ctx = PolicyContext::from_session(&session, "user_id").await;
1243        let target = ImpersonationTarget::new("u9");
1244
1245        assert!(
1246            !ImpersonationGate::deny_all()
1247                .policy()
1248                .can_impersonate(&ctx, &target)
1249                .await
1250        );
1251        assert!(
1252            ImpersonationGate::allow_roles(["support", "admin"])
1253                .policy()
1254                .can_impersonate(&ctx, &target)
1255                .await
1256        );
1257        assert!(
1258            !ImpersonationGate::allow_roles(["support"])
1259                .policy()
1260                .can_impersonate(&ctx, &target)
1261                .await
1262        );
1263    }
1264
1265    #[tokio::test]
1266    async fn the_default_target_role_is_none_so_no_role_is_inherited() {
1267        let session = session_with(&[("user_id", "admin-1"), ("role", "admin")]);
1268        let ctx = PolicyContext::from_session(&session, "user_id").await;
1269        let target = ImpersonationTarget::new("u9");
1270        assert_eq!(
1271            ImpersonationGate::allow_roles(["admin"])
1272                .policy()
1273                .target_role(&ctx, &target)
1274                .await,
1275            None,
1276            "an impersonated session must never inherit the admin's role"
1277        );
1278    }
1279}