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}