autumn-web 0.9.0

An opinionated, convention-over-configuration web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
//! Admin **user impersonation** — "log in as this user", without losing the
//! real admin's identity (issue #1394).
//!
//! Every support team eventually needs to reproduce a bug, verify a
//! permission, or walk a customer through a screen *as that customer*. Doing it
//! by hand — `session.insert("user_id", target)` — silently destroys the audit
//! trail: from that moment on every version row and audit event claims the
//! customer did it. This module is the primitive that makes the secure version
//! the easy one.
//!
//! # What it does
//!
//! [`begin_impersonation`] swaps the session's **effective** user to the target
//! and records the real admin separately under [`IMPERSONATOR_SESSION_KEY`].
//! Because the effective user lives in the ordinary auth session key,
//! everything the framework resolves "the current user" from — `#[secured]`,
//! [`RequireAuth`](super::RequireAuth), [`PolicyContext`] — transparently sees
//! the *impersonated* user, exactly as if they had logged in. ([`Auth<T>`] is
//! populated by the app's own loader middleware from request extensions, so it
//! follows only if that loader reads the auth session key.) Meanwhile the ambient
//! [current actor](crate::current::Current) — the value that seeds
//! `#[repository(versioned)]` version rows and audit events — is published as
//! the **real impersonator**, so writes made during the session stay honestly
//! attributed.
//!
//! [`end_impersonation`] reverses it, restoring the admin's user id and role.
//! Both directions rotate the session id (no fixation) and write an audit
//! event naming both parties.
//!
//! # Default-deny
//!
//! Beginning impersonation requires an [`ImpersonationGate`] installed in
//! [`AppState`]. With no gate registered every attempt is refused with `403` —
//! an app opts in explicitly:
//!
//! ```rust,no_run
//! use autumn_web::app::AppBuilder;
//! use autumn_web::auth::impersonation::ImpersonationGate;
//!
//! # fn wire(app: AppBuilder) -> AppBuilder {
//! app.impersonation_gate(ImpersonationGate::allow_roles(["admin"]))
//! # }
//! ```
//!
//! `autumn-admin-plugin` wraps this in `AdminPlugin::with_impersonation(gate)`,
//! which registers the gate *and* mounts the begin/revert routes plus the
//! "Viewing as … — Stop impersonating" banner.
//!
//! # An audit sink is required
//!
//! [`begin_impersonation`] refuses with `500` unless the app has an
//! [`AuditLogger`](crate::audit::AuditLogger) with at least one sink installed
//! — `audit::write_from_state` is a silent no-op without one, and an
//! unrecorded identity swap is exactly what this module exists to prevent.
//! `AppBuilder::with_audit_sink(TracingAuditSink)` is the minimum; a durable
//! sink (`JsonlFileAuditSink`, or your own) is what you actually want. Ending
//! an impersonation is deliberately *not* subject to this: dropping privilege
//! must always succeed.
//!
//! # Scope
//!
//! Session-based auth only: an API-token principal has no session to swap, so
//! the token authentication path is untouched. Same-tenant only — the
//! [`ImpersonationPolicy`] is the seam where an app enforces its tenancy
//! boundary (it receives the full [`PolicyContext`], including the session and
//! the DB pool) — the framework itself enforces no tenancy, and
//! [`ImpersonationGate::allow_roles`] in particular does not look at the target
//! at all, so a multi-tenant app wants a real [`ImpersonationPolicy`].
//!
//! Step-up does **not** carry over: [`begin_impersonation`] stashes the
//! operator's `last_strong_auth_at` claim and drops it from the impersonated
//! session, so a `#[step_up]` route cannot run a sensitive action on the
//! target's account on the strength of the *operator's* re-authentication.
//! [`end_impersonation`] restores the operator's own claim.
//!
//! # Extra identity keys
//!
//! Only the auth session key swaps by default. If the app stores other
//! identity values in the session (for example `user_email`), return those
//! keys from [`ImpersonationPolicy::target_session_values`].
//! [`begin_impersonation`] swaps them. [`end_impersonation`] restores the
//! operator's values. [`clear`] removes them and restores nothing.
//!
//! # Reserved session keys
//!
//! [`RESERVED_SESSION_KEYS`] belong to the framework; do not configure
//! `[auth].session_key` to collide with them (impersonation refuses outright if
//! you do, and logs it at startup), and do not write them by hand. Call
//! [`clear`] from any flow that
//! replaces the session's user outright (a login, a magic-link or passkey
//! promotion) so a record left behind by an operator who never reverted is not
//! inherited by whoever logs in next.
//!
//! [`Auth<T>`]: super::Auth
//!
//! [`AppState`]: crate::AppState
//! [`PolicyContext`]: crate::authorization::PolicyContext

use std::sync::Arc;

use crate::AppState;
use crate::audit::{AuditEvent, AuditStatus};
use crate::authorization::{BoxFuture, PolicyContext};
use crate::current::Current;
use crate::session::Session;

/// Session key holding the **real** admin's id while impersonation is active.
///
/// Reserved by the framework: application code must not write it directly —
/// doing so would forge an impersonation that never passed the
/// [`ImpersonationGate`] and never produced an audit event. Read it through
/// [`impersonator_id`] instead.
pub const IMPERSONATOR_SESSION_KEY: &str = "impersonator_id";

/// Session key recording **which** user the impersonation record describes.
///
/// The record is only honored while this still matches the session's effective
/// user. Without that binding, a stale record left behind by an operator who
/// never reverted would be picked up by whoever logs in on that session next —
/// handing them the admin's identity through the revert route, and
/// misattributing their writes to the admin in the meantime. Reserved by the
/// framework, like [`IMPERSONATOR_SESSION_KEY`].
pub const IMPERSONATED_SESSION_KEY: &str = "impersonated_id";

/// Session key stashing the real admin's role for the duration of the
/// impersonation, so [`end_impersonation`] can restore it exactly. Reserved by
/// the framework, like [`IMPERSONATOR_SESSION_KEY`].
pub const IMPERSONATOR_ROLE_SESSION_KEY: &str = "impersonator_role";

/// Session key binding the record to the session generation that created it.
///
/// Holds the session id [`begin_impersonation`] minted when it rotated. Any
/// later rotation — which every login is required to perform, to prevent
/// fixation — leaves the record pointing at a generation that no longer exists,
/// so it is treated as stale. Without this, a customer who logs in as
/// *themselves* on a browser where they had just been impersonated would still
/// match the recorded target, and the revert route would hand them the
/// operator's identity. Reserved by the framework, like
/// [`IMPERSONATOR_SESSION_KEY`].
pub const IMPERSONATION_SESSION_ID_KEY: &str = "impersonation_session_id";

/// Session key stashing the real admin's step-up claim.
///
/// Holds [`step_up::STEP_UP_SESSION_KEY`](crate::step_up::STEP_UP_SESSION_KEY)
/// for the duration of the impersonation, so the impersonated session cannot
/// spend the operator's freshness. Reserved by the framework, like
/// [`IMPERSONATOR_SESSION_KEY`].
pub const IMPERSONATOR_STEP_UP_SESSION_KEY: &str = "impersonator_last_strong_auth_at";

/// Session key stashing the operator's values for the extra identity keys.
///
/// The value is a JSON object. It maps each key to the operator's old value,
/// or to `null` if the key was unset. [`begin_impersonation`] writes it for
/// each key that [`ImpersonationPolicy::target_session_values`] returns.
/// [`end_impersonation`] reads it. Reserved by the framework, like
/// [`IMPERSONATOR_SESSION_KEY`].
pub const IMPERSONATOR_IDENTITY_SESSION_KEY: &str = "impersonator_identity_keys";

/// Session key holding the current role. Mirrors the key `#[secured("role")]`
/// and the admin plugin's role middleware read.
const ROLE_SESSION_KEY: &str = "role";

/// Every session key the impersonation swap must not collide with.
///
/// Most are keys impersonation writes for its own bookkeeping; the live
/// step-up key is read and stashed by the swap rather than written, but it is
/// reserved all the same — the swap moves the operator's claim aside and then
/// writes the target id through the configured auth key, so a colliding
/// configuration would let a numeric target id satisfy step-up with no
/// reauthentication.
/// An app must not configure `[auth].session_key` (or write by hand) any key in
/// this list: the swap would then clobber its own record.
/// [`begin_impersonation`] refuses rather than corrupting the session, and
/// `AppBuilder::impersonation_gate` logs the misconfiguration at startup.
pub const RESERVED_SESSION_KEYS: [&str; 8] = [
    IMPERSONATOR_SESSION_KEY,
    IMPERSONATED_SESSION_KEY,
    IMPERSONATION_SESSION_ID_KEY,
    IMPERSONATOR_ROLE_SESSION_KEY,
    IMPERSONATOR_STEP_UP_SESSION_KEY,
    IMPERSONATOR_IDENTITY_SESSION_KEY,
    ROLE_SESSION_KEY,
    crate::step_up::STEP_UP_SESSION_KEY,
];

/// Whether `key` is one of the [`RESERVED_SESSION_KEYS`].
///
/// Use it to validate a configured `[auth].session_key` at startup, the way the
/// framework's TOTP-pending keys are validated.
#[must_use]
pub fn is_reserved_session_key(key: &str) -> bool {
    RESERVED_SESSION_KEYS.contains(&key)
}

/// Audit action recorded when impersonation begins.
pub const BEGIN_AUDIT_ACTION: &str = "auth.impersonation.begin";

/// Audit action recorded when impersonation ends.
pub const END_AUDIT_ACTION: &str = "auth.impersonation.end";

// ── Target ───────────────────────────────────────────────────────

/// The user an operator is asking to impersonate.
///
/// Constructed from any string-like id; `role` is an **optional, trusted,
/// server-side** decision about which role the impersonated session should
/// carry. It must never be populated from request input — that would let an
/// operator mint a session more privileged than the target really is. Leave it
/// unset and let [`ImpersonationPolicy::target_role`] resolve it from the app's
/// own user store (which is what `autumn-admin-plugin`'s route does).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImpersonationTarget {
    user_id: String,
    role: Option<String>,
}

impl ImpersonationTarget {
    /// A target identified by user id, with no role decided yet.
    #[must_use]
    pub fn new(user_id: impl Into<String>) -> Self {
        Self {
            user_id: user_id.into(),
            role: None,
        }
    }

    /// Attach the role the impersonated session should carry.
    ///
    /// Trusted input only — see the type-level note.
    #[must_use]
    pub fn with_role(mut self, role: impl Into<String>) -> Self {
        self.role = Some(role.into());
        self
    }

    /// The target user's id.
    #[must_use]
    pub fn user_id(&self) -> &str {
        &self.user_id
    }

    /// The explicitly-attached role, if any.
    #[must_use]
    pub fn role(&self) -> Option<&str> {
        self.role.as_deref()
    }

    /// The same target with its user id trimmed of surrounding whitespace.
    ///
    /// [`begin_impersonation`] applies this **before** the
    /// [`ImpersonationPolicy`] ever sees the target, so the id the policy
    /// authorizes, the id the audit event records, and the id written to the
    /// session are always one and the same string.
    #[must_use]
    pub fn normalized(mut self) -> Self {
        self.user_id = self.user_id.trim().to_owned();
        self
    }
}

impl From<String> for ImpersonationTarget {
    fn from(user_id: String) -> Self {
        Self::new(user_id)
    }
}

impl From<&str> for ImpersonationTarget {
    fn from(user_id: &str) -> Self {
        Self::new(user_id)
    }
}

impl From<&String> for ImpersonationTarget {
    fn from(user_id: &String) -> Self {
        Self::new(user_id)
    }
}

// ── Policy + gate ────────────────────────────────────────────────

/// Decides whether the current principal may impersonate a given target.
///
/// Default-deny: [`can_impersonate`](Self::can_impersonate) has no default
/// implementation, and an app with no gate registered refuses every attempt.
///
/// ```rust
/// use autumn_web::authorization::{BoxFuture, PolicyContext};
/// use autumn_web::auth::impersonation::{ImpersonationPolicy, ImpersonationTarget};
///
/// struct SupportDesk;
///
/// impl ImpersonationPolicy for SupportDesk {
///     fn can_impersonate<'a>(
///         &'a self,
///         ctx: &'a PolicyContext,
///         target: &'a ImpersonationTarget,
///     ) -> BoxFuture<'a, bool> {
///         // Same-tenant only: consult the app's own store through `ctx`.
///         Box::pin(async move { ctx.has_role("support") && target.user_id() != "root" })
///     }
/// }
/// ```
pub trait ImpersonationPolicy: Send + Sync + 'static {
    /// `true` to allow the impersonation, `false` to refuse it with `403`.
    fn can_impersonate<'a>(
        &'a self,
        ctx: &'a PolicyContext,
        target: &'a ImpersonationTarget,
    ) -> BoxFuture<'a, bool>;

    /// The role the impersonated session should carry, resolved **server-side**
    /// from the app's own user store.
    ///
    /// Only consulted when the [`ImpersonationTarget`] carries no explicit
    /// role. Defaults to `None`, which drops the role entirely for the duration
    /// of the impersonation — the safe default, since the framework cannot know
    /// what role the target really holds and inheriting the *admin's* role would
    /// be a straight privilege escalation.
    fn target_role<'a>(
        &'a self,
        _ctx: &'a PolicyContext,
        _target: &'a ImpersonationTarget,
    ) -> BoxFuture<'a, Option<String>> {
        Box::pin(async { None })
    }

    /// Extra session keys to swap to the target, as `(key, value)` pairs.
    /// A `None` value means the target has no value: the key is removed for the
    /// impersonation, and the operator's value returns on the end.
    ///
    /// Use this when the app stores identity in keys other than the auth key,
    /// for example `user_email`. [`begin_impersonation`] saves the operator's
    /// value for each key and writes the target's value.
    /// [`end_impersonation`] restores the operator's values. Resolve the values
    /// server-side from the app's user store.
    ///
    /// Default: no keys. If the policy returns a reserved key (see
    /// [`RESERVED_SESSION_KEYS`]) or the auth key, [`begin_impersonation`]
    /// fails with `500`. The policy must not return other keys the framework
    /// reads, such as the flash, locale or time-zone keys.
    fn target_session_values<'a>(
        &'a self,
        _ctx: &'a PolicyContext,
        _target: &'a ImpersonationTarget,
    ) -> BoxFuture<'a, Vec<(String, Option<String>)>> {
        Box::pin(async { Vec::new() })
    }
}

/// Refuses everything. The behavior an app gets when no gate is registered.
struct DenyAll;

impl ImpersonationPolicy for DenyAll {
    fn can_impersonate<'a>(
        &'a self,
        _ctx: &'a PolicyContext,
        _target: &'a ImpersonationTarget,
    ) -> BoxFuture<'a, bool> {
        Box::pin(async { false })
    }
}

/// Allows any principal holding one of the listed roles.
struct AllowRoles(Vec<String>);

impl ImpersonationPolicy for AllowRoles {
    fn can_impersonate<'a>(
        &'a self,
        ctx: &'a PolicyContext,
        _target: &'a ImpersonationTarget,
    ) -> BoxFuture<'a, bool> {
        Box::pin(async move { ctx.has_any_role(&self.0) })
    }
}

/// The installed impersonation authorization gate.
///
/// Register one in [`AppState`](crate::AppState) to opt an app into
/// impersonation; without it [`begin_impersonation`] always returns `403`.
#[derive(Clone)]
pub struct ImpersonationGate(Arc<dyn ImpersonationPolicy>);

impl ImpersonationGate {
    /// A gate that refuses everything — the framework default, spelled out.
    #[must_use]
    pub fn deny_all() -> Self {
        Self(Arc::new(DenyAll))
    }

    /// Allow any principal whose session role is one of `roles`.
    ///
    /// The one-liner for "our `admin` role may impersonate":
    /// `ImpersonationGate::allow_roles(["admin"])`. The impersonated session
    /// still receives **no** role (see [`ImpersonationPolicy::target_role`]);
    /// use [`custom`](Self::custom) to resolve the target's real role.
    #[must_use]
    pub fn allow_roles<I, S>(roles: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        Self(Arc::new(AllowRoles(
            roles.into_iter().map(Into::into).collect(),
        )))
    }

    /// Install an application-defined [`ImpersonationPolicy`].
    #[must_use]
    pub fn custom<P: ImpersonationPolicy>(policy: P) -> Self {
        Self(Arc::new(policy))
    }

    /// Borrow the underlying policy.
    #[must_use]
    pub fn policy(&self) -> &dyn ImpersonationPolicy {
        &*self.0
    }
}

impl std::fmt::Debug for ImpersonationGate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("ImpersonationGate")
    }
}

// ── State ────────────────────────────────────────────────────────

/// A snapshot of an active impersonation: who is really acting, and as whom.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ImpersonationState {
    /// The **real** operator — the id audit and version writes are attributed
    /// to while the impersonation is active.
    pub impersonator_id: String,
    /// The user the session currently resolves as.
    pub effective_user_id: String,
}

/// The impersonation record on the session, if it is still live.
///
/// Returns `(impersonator_id, impersonated_id)` only when all three reserved
/// keys are present **and** the record is bound to the current session
/// generation. Callers must additionally confirm `impersonated_id` matches the
/// session's effective user — see [`audit_actor_id`], the validating form every
/// framework seam uses.
///
/// The generation check is what makes the record un-inheritable: a login (which
/// must rotate the session id) leaves the record bound to a generation that no
/// longer exists, so it stops being honored even if the person logging in is
/// the very user who was being impersonated.
async fn live_record(session: &Session) -> Option<(String, String)> {
    let impersonator = session.get(IMPERSONATOR_SESSION_KEY).await?;
    let impersonated = session.get(IMPERSONATED_SESSION_KEY).await?;
    let bound_session_id = session.get(IMPERSONATION_SESSION_ID_KEY).await?;
    (bound_session_id == session.id().await).then_some((impersonator, impersonated))
}

/// The real operator behind the current session, or `None` when the session is
/// not impersonating.
///
/// The companion to ordinary current-user resolution: `session.get("user_id")`
/// (and everything built on it) answers *"who does this request act as?"*, this
/// answers *"who is really doing it?"*.
///
/// Validated: a record whose recorded target no longer matches the session's
/// effective user is stale — someone logged in on this session after an
/// operator walked away without reverting — and is reported as "not
/// impersonating" rather than silently attributing that person's work to the
/// operator. Clear such a record with [`clear`].
pub async fn impersonator_id(state: &AppState, session: &Session) -> Option<String> {
    impersonation_state(state, session)
        .await
        .map(|active| active.impersonator_id)
}

/// Whether the session is currently impersonating someone.
///
/// Validated the same way as [`impersonator_id`].
pub async fn is_impersonating(state: &AppState, session: &Session) -> bool {
    impersonation_state(state, session).await.is_some()
}

/// The full [`ImpersonationState`] for the session.
///
/// `None` when the session is not impersonating. Reads the effective user
/// through the app's configured auth session key and verifies the record still
/// describes that user.
pub async fn impersonation_state(
    state: &AppState,
    session: &Session,
) -> Option<ImpersonationState> {
    let effective_user_id = session.get(state.auth_session_key()).await?;
    let (impersonator_id, impersonated_id) = live_record(session).await?;
    (impersonated_id == effective_user_id).then_some(ImpersonationState {
        impersonator_id,
        effective_user_id,
    })
}

/// Drop any impersonation record from the session.
///
/// Audits nothing and restores nothing. It removes the extra identity keys that
/// the impersonation swapped in.
///
/// This is **not** the revert — use [`end_impersonation`] for that. It exists
/// for identity transitions that replace the session's user outright, where the
/// old record no longer describes anyone: a login, a magic-link or passkey
/// promotion, an account switch. Call it wherever your app writes the auth
/// session key directly, so a record left behind by an operator who never
/// reverted cannot be inherited by the next person to log in on that session.
///
/// ```rust,no_run
/// # use autumn_web::session::Session;
/// # use autumn_web::auth::impersonation;
/// async fn establish_session(session: &Session, user_id: &str) {
///     session.rotate_id().await;
///     impersonation::clear(session).await;
///     session.insert("user_id", user_id).await;
/// }
/// ```
pub async fn clear(session: &Session) {
    session.remove(IMPERSONATOR_SESSION_KEY).await;
    session.remove(IMPERSONATED_SESSION_KEY).await;
    session.remove(IMPERSONATION_SESSION_ID_KEY).await;
    session.remove(IMPERSONATOR_ROLE_SESSION_KEY).await;
    session.remove(IMPERSONATOR_STEP_UP_SESSION_KEY).await;
    drop_identity_keys(session).await;
}

/// Keep a live impersonation across a rotation that did not change who is
/// signed in.
///
/// The record is bound to the session id it was created under, so any later
/// `rotate_id()` retires it. For a rotation you trust — an idle or periodic
/// rotation policy, not a login — call this right after `rotate_id()` to move
/// the binding to the new id. Returns `true` when the record was kept.
///
/// It refuses (returns `false`) unless the record was bound to the id just
/// before the latest rotation and the session still resolves as the
/// impersonated user. Call it right after each `rotate_id()`. A record already retired by an earlier rotation is never revived.
///
/// Do **not** call it from a login flow. A login by the impersonated user
/// matches the same checks, and the rebind would hand them the record. Login
/// flows call [`clear`] instead.
///
/// ```rust,no_run
/// # use autumn_web::{session::Session, AppState};
/// # use autumn_web::auth::impersonation;
/// async fn rotate_on_idle(state: &AppState, session: &Session) {
///     session.rotate_id().await;
///     impersonation::rebind(state, session).await;
/// }
/// ```
pub async fn rebind(state: &AppState, session: &Session) -> bool {
    let previous_id = session.previous_id().await;
    let bound = session.get(IMPERSONATION_SESSION_ID_KEY).await;
    let impersonated = session.get(IMPERSONATED_SESSION_KEY).await;
    let effective = session.get(state.auth_session_key()).await;
    let still_live = bound.is_some()
        && bound == previous_id
        && session.contains_key(IMPERSONATOR_SESSION_KEY).await
        && impersonated.is_some()
        && impersonated == effective;
    if still_live {
        session
            .insert(IMPERSONATION_SESSION_ID_KEY, session.id().await)
            .await;
    }
    still_live
}

/// The id that audit and version writes made by this session should carry.
///
/// Returns the real impersonator while impersonation is active, and
/// `effective_user_id` otherwise. This is the single rule the framework's
/// session-based [`Current::set_actor`] seams apply, so
/// `#[repository(versioned)]` writes stay attributed to the human responsible,
/// and so do [`AuditEvent`]s — for the audit call sites the framework owns,
/// which read the acting user from [`Current`].
///
/// Hand-rolled audit events do not get this for free: [`AuditEvent::new`]
/// takes an explicit `actor_id`, so a handler that builds its event from the
/// effective session user still records the *customer* while impersonation is
/// active — the exact misattribution impersonation exists to prevent, in the
/// one place an integrator is most likely to hand-roll. Pass
/// `audit_actor_id(session, user).await` (or `Current::actor()`) as `actor_id`
/// there.
///
/// Validating by construction: the caller supplies the effective user, so a
/// stale record — one describing a *different* user than the session now
/// resolves as — is ignored and the effective user is returned. That keeps a
/// forgotten impersonation from misattributing the next person's writes to the
/// operator.
pub async fn audit_actor_id(session: &Session, effective_user_id: &str) -> String {
    match live_record(session).await {
        Some((impersonator, impersonated)) if impersonated == effective_user_id => impersonator,
        _ => effective_user_id.to_owned(),
    }
}

/// The id a per-user throttle should key on for `session`.
///
/// The real operator while impersonating, otherwise the effective user. An
/// operator then spends their own budget and cannot throttle the customer out
/// of their account. `None` when the session holds no user.
pub(crate) async fn throttle_principal_id(session: &Session, auth_key: &str) -> Option<String> {
    let effective = session.get(auth_key).await?;
    Some(audit_actor_id(session, &effective).await)
}

// ── Extractor ────────────────────────────────────────────────────

/// Request extractor resolving the session's active impersonation, if any.
///
/// Infallible — a request with no session, or one that is not impersonating,
/// yields `Impersonation(None)` — so a handler can take it unconditionally and
/// branch on it. The ergonomic form of [`impersonation_state`] for apps that use
/// the core primitive without `autumn-admin-plugin`.
///
/// ```rust,no_run
/// use autumn_web::prelude::*;
/// use autumn_web::auth::impersonation::Impersonation;
///
/// #[autumn_web::get("/")]
/// async fn home(impersonation: Impersonation) -> String {
///     match impersonation.state() {
///         Some(active) => format!("viewing as {}", active.effective_user_id),
///         None => "your own account".to_owned(),
///     }
/// }
/// ```
#[derive(Debug, Clone, Default)]
pub struct Impersonation(Option<ImpersonationState>);

impl Impersonation {
    /// The active impersonation, or `None` when the session is not
    /// impersonating (or there is no session).
    #[must_use]
    pub const fn state(&self) -> Option<&ImpersonationState> {
        self.0.as_ref()
    }

    /// Consume the extractor, yielding the active impersonation.
    #[must_use]
    pub fn into_state(self) -> Option<ImpersonationState> {
        self.0
    }

    /// Whether the request is running under an impersonation.
    #[must_use]
    pub const fn is_active(&self) -> bool {
        self.0.is_some()
    }
}

impl axum::extract::FromRequestParts<AppState> for Impersonation {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut http::request::Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let Some(session) = parts.extensions.get::<Session>().cloned() else {
            return Ok(Self(None));
        };
        Ok(Self(impersonation_state(state, &session).await))
    }
}

// ── Begin / end ──────────────────────────────────────────────────

/// Move `session` from the operator's identity onto the target's.
///
/// Only the impersonation-relevant keys move; everything else in the session
/// (flash, CSRF, wizard progress) is deliberately preserved. The operator's
/// role and step-up claim are stashed rather than discarded, so
/// [`end_impersonation`] can restore them exactly.
async fn swap_identity(
    session: &Session,
    auth_key: &str,
    real_id: &str,
    target_id: &str,
    target_role: Option<&str>,
    extra: &[(String, Option<String>)],
) {
    session.insert(IMPERSONATOR_SESSION_KEY, real_id).await;
    stash_identity_keys(session, extra).await;
    session.insert(IMPERSONATED_SESSION_KEY, target_id).await;
    stash(session, ROLE_SESSION_KEY, IMPERSONATOR_ROLE_SESSION_KEY).await;
    // `last_strong_auth_at` is a bare timestamp with no identity bound to it,
    // so carrying it across the swap would let a `#[step_up]` route run a
    // destructive action *on the target's account* on the strength of the
    // operator's re-authentication — impersonation laundering a credential
    // check. Every other identity transition in the framework drops this key
    // for the same reason.
    stash(
        session,
        crate::step_up::STEP_UP_SESSION_KEY,
        IMPERSONATOR_STEP_UP_SESSION_KEY,
    )
    .await;

    session.insert(auth_key, target_id).await;
    set_or_remove(session, ROLE_SESSION_KEY, target_role).await;
    // Privilege change ⇒ new session id (no fixation). Bind the record to the
    // generation this rotation mints, so the next rotation — a login, an
    // account switch — retires the record instead of letting whoever comes next
    // inherit it.
    session.rotate_id().await;
    session
        .insert(IMPERSONATION_SESSION_ID_KEY, session.id().await)
        .await;
}

/// Save the operator's value for each extra key, then write the target's.
async fn stash_identity_keys(session: &Session, extra: &[(String, Option<String>)]) {
    // A stale stash names keys of an old target. Remove them and the stash, so
    // nothing of the old record shows or returns. Restore nothing.
    drop_identity_keys(session).await;
    if extra.is_empty() {
        return;
    }
    let mut saved = serde_json::Map::new();
    for (key, value) in extra {
        // Save the operator's value only once. A repeated key must not
        // overwrite the saved value.
        if !saved.contains_key(key) {
            let old = session.get(key).await;
            saved.insert(key.clone(), old.map_or(serde_json::Value::Null, Into::into));
        }
        set_or_remove(session, key, value.as_deref()).await;
    }
    session
        .insert(
            IMPERSONATOR_IDENTITY_SESSION_KEY,
            serde_json::Value::Object(saved).to_string(),
        )
        .await;
}

/// Remove the extra keys named in the stash, and the stash. Restores nothing.
async fn drop_identity_keys(session: &Session) {
    let Some(raw) = session.remove(IMPERSONATOR_IDENTITY_SESSION_KEY).await else {
        return;
    };
    let Ok(saved) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw) else {
        return;
    };
    for key in saved.keys().filter(|key| !is_reserved_session_key(key)) {
        session.remove(key).await;
    }
}

/// Put back the operator's extra-key values saved by [`stash_identity_keys`].
async fn restore_identity_keys(session: &Session) {
    let Some(raw) = session.remove(IMPERSONATOR_IDENTITY_SESSION_KEY).await else {
        return;
    };
    let Ok(saved) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&raw) else {
        return;
    };
    for (key, value) in saved {
        // Never write a reserved key from stored data.
        if is_reserved_session_key(&key) {
            continue;
        }
        set_or_remove(session, &key, value.as_str()).await;
    }
}

/// Move `from` to `to`, leaving neither key set when `from` was absent.
async fn stash(session: &Session, from: &str, to: &str) {
    let value = session.remove(from).await;
    set_or_remove(session, to, value.as_deref()).await;
}

/// Set `key` to `value`, or remove it entirely when `value` is `None`.
async fn set_or_remove(session: &Session, key: &str, value: Option<&str>) {
    match value {
        Some(value) => session.insert(key, value).await,
        None => {
            session.remove(key).await;
        }
    }
}

/// Everything [`begin_impersonation`] must establish before it consults the
/// [`ImpersonationGate`]: a usable auth session key, an authenticated operator,
/// a non-empty target that is not the operator themselves, and no impersonation
/// already in flight.
///
/// Returns the resolved `(auth_key, real_id)` on success.
async fn check_begin_preconditions(
    state: &AppState,
    session: &Session,
    target_id: &str,
) -> crate::AutumnResult<(String, String)> {
    let auth_key = state.auth_session_key().to_owned();
    // A configured auth key that collides with one of this module's own keys
    // makes the swap self-destructive: writing the target through `auth_key`
    // would overwrite the record it just wrote, so attribution would follow the
    // target and the revert could not restore the operator. Refuse the
    // misconfiguration rather than corrupting the session — checked before the
    // session is read at all, so the error names the real cause.
    if is_reserved_session_key(&auth_key) {
        return Err(reserved_auth_key_error(&auth_key));
    }

    let Some(real_id) = session.get(&auth_key).await else {
        return Err(crate::AutumnError::unauthorized_msg(
            "authentication required",
        ));
    };

    if target_id.is_empty() {
        return Err(crate::AutumnError::bad_request_msg(
            "impersonation target is required",
        ));
    }

    // No nesting: an already-impersonated session cannot start a second hop,
    // so impersonation can never be chained into an escalation. Uses the
    // validated form, so a *stale* record (one describing a user this session
    // no longer resolves as) does not permanently wedge the session at 409.
    if is_impersonating(state, session).await {
        return Err(crate::AutumnError::conflict_msg(
            "already impersonating; stop the current impersonation first",
        ));
    }

    if target_id == real_id.trim() {
        return Err(crate::AutumnError::bad_request_msg(
            "cannot impersonate yourself",
        ));
    }

    Ok((auth_key, real_id))
}

/// The error both directions return when `auth.session_key` collides with a key
/// the impersonation record reserves.
fn reserved_auth_key_error(auth_key: &str) -> crate::AutumnError {
    crate::AutumnError::internal_server_error_msg(format!(
        "impersonation refused: `auth.session_key` is set to \"{auth_key}\", which is \
         reserved by the impersonation record; choose another key"
    ))
}

/// Begin impersonating `target` from the current admin session.
///
/// On success the session's effective user becomes the target, the real admin
/// is recorded under [`IMPERSONATOR_SESSION_KEY`], the session id is rotated,
/// and a [`BEGIN_AUDIT_ACTION`] audit event carrying
/// `{impersonator_id, target_id}` is written.
///
/// ```rust,no_run
/// use autumn_web::prelude::*;
/// use autumn_web::auth::impersonation;
///
/// #[autumn_web::post("/support/impersonate/{user_id}")]
/// #[autumn_web::secured("support")]
/// async fn impersonate(
///     State(state): State<AppState>,
///     session: Session,
///     Path(user_id): Path<String>,
/// ) -> AutumnResult<Redirect> {
///     impersonation::begin_impersonation(&state, &session, user_id).await?;
///     Ok(Redirect::to("/"))
/// }
/// ```
///
/// # Errors
///
/// * `400` — the target id is blank, or names the caller themselves.
/// * `401` — the session is not authenticated.
/// * `403` — no [`ImpersonationGate`] is installed, or the installed policy
///   refused. The refusal is itself audited as a failure.
/// * `409` — the session is **already** impersonating. Impersonation does not
///   nest, so it cannot be chained to escalate.
/// * `500` — no audit sink is configured, or the audit event could not be
///   written. A privileged identity swap that cannot be recorded does not
///   happen at all, so an app must register an [`AuditSink`](crate::audit::AuditSink)
///   before enabling impersonation.
pub async fn begin_impersonation(
    state: &AppState,
    session: &Session,
    target: impl Into<ImpersonationTarget>,
) -> crate::AutumnResult<ImpersonationState> {
    // Normalize ONCE, before anything sees the target. The policy, the audit
    // event, and the session write must all reason about the same string: if the
    // gate were handed a raw `" root "` while the session received `"root"`, a
    // policy denying `"root"` would authorize an id it never approved.
    let target = target.into().normalized();
    let target_id = target.user_id();
    let (auth_key, real_id) = check_begin_preconditions(state, session, target_id).await?;

    let ctx = PolicyContext::from_request(state, session).await;
    let gate = state.extension::<ImpersonationGate>();
    let allowed = match gate.as_deref() {
        Some(gate) => gate.policy().can_impersonate(&ctx, &target).await,
        // Default-deny: an app that never opted in refuses every attempt.
        None => false,
    };

    if !allowed {
        let _ = crate::audit::write_from_state(
            state,
            AuditEvent::new(
                &real_id,
                BEGIN_AUDIT_ACTION,
                target_id,
                None,
                AuditStatus::Failure,
            ),
        )
        .await;
        return Err(crate::AutumnError::forbidden_msg(
            "not permitted to impersonate",
        ));
    }

    // Check the extra keys before any write. A bad key then fails the begin
    // and leaves the session unchanged.
    let extra = match gate.as_deref() {
        Some(gate) => gate.policy().target_session_values(&ctx, &target).await,
        None => Vec::new(),
    };
    if let Some((key, _)) = extra
        .iter()
        .find(|(key, _)| key == &auth_key || is_reserved_session_key(key))
    {
        return Err(crate::AutumnError::internal_server_error_msg(format!(
            "impersonation refused: `target_session_values` returned the key \"{key}\", \
             which is the auth key or a reserved key"
        )));
    }

    // Fail closed on a *missing* sink, not just a failing one.
    // `audit::write_from_state` deliberately returns `Ok(())` when no
    // `AuditLogger` is installed, so without this check an app that enabled
    // impersonation but never configured a sink would swap identities with no
    // record at all — precisely the outcome this module exists to prevent.
    // Checked after the authorization gate so an unauthorized caller still gets
    // a plain `403` and learns nothing about the app's audit configuration.
    let audit_enabled = state
        .extension::<crate::audit::AuditLogger>()
        .is_some_and(|logger| logger.is_enabled());
    if !audit_enabled {
        return Err(crate::AutumnError::internal_server_error_msg(
            "impersonation refused: no audit sink is configured. Register one \
             (e.g. `AppBuilder::with_audit_sink(TracingAuditSink)`) before \
             enabling impersonation — an unrecorded identity swap is exactly \
             what this feature exists to prevent",
        ));
    }

    // Audit *before* the swap, and fail closed: an identity swap that cannot be
    // recorded must not take effect. The worst case is an over-recorded event,
    // never a silent one.
    crate::audit::write_from_state(
        state,
        AuditEvent::new(
            &real_id,
            BEGIN_AUDIT_ACTION,
            target_id,
            None,
            AuditStatus::Success,
        ),
    )
    .await
    .map_err(|error| {
        crate::AutumnError::internal_server_error_msg(format!(
            "impersonation refused: audit write failed: {error}"
        ))
    })?;

    // Resolve the impersonated role server-side only. An explicit role on the
    // target wins (a trusted, in-process caller decided it); otherwise the
    // policy resolves it from the app's own user store; otherwise the session
    // carries no role at all — never the admin's.
    let target_role = match target.role() {
        Some(role) => Some(role.to_owned()),
        None => match gate.as_deref() {
            Some(gate) => gate.policy().target_role(&ctx, &target).await,
            None => None,
        },
    };

    swap_identity(
        session,
        &auth_key,
        &real_id,
        target_id,
        target_role.as_deref(),
        &extra,
    )
    .await;

    // The remainder of *this* request is the impersonator's work too — but only
    // seed the actor if nothing stronger already resolved one, matching the
    // "first/outermost resolver wins" rule the three session seams follow. On
    // every session-authenticated path the actor is already this same
    // `real_id` by now (the seam, or the `PolicyContext` built above, seeded
    // it), so this changes nothing there; it matters when an API-token bearer
    // or an explicit `with_actor(...)` scope resolved the principal first, and
    // clobbering that would misattribute the rest of the handler's writes.
    if Current::actor().is_none() {
        Current::set_actor(real_id.clone());
    }

    Ok(ImpersonationState {
        impersonator_id: real_id,
        effective_user_id: target_id.to_owned(),
    })
}

/// End the active impersonation and restore the original admin session.
///
/// Restores the admin's user id and role, clears the impersonation keys,
/// rotates the session id, and writes an [`END_AUDIT_ACTION`] audit event
/// carrying `{impersonator_id, target_id}`.
///
/// Deliberately **not** gated by the [`ImpersonationGate`]: reverting is always
/// permitted, so an operator impersonating a user who has lost the granting
/// role is never trapped in that identity.
///
/// # Errors
///
/// Returns `400` when the session is not impersonating. An audit-sink failure
/// is logged but does not block the revert — the opposite trade-off from
/// [`begin_impersonation`], because refusing to *drop* privilege would be the
/// less safe outcome.
pub async fn end_impersonation(
    state: &AppState,
    session: &Session,
) -> crate::AutumnResult<ImpersonationState> {
    let auth_key = state.auth_session_key().to_owned();
    if is_reserved_session_key(&auth_key) {
        return Err(reserved_auth_key_error(&auth_key));
    }
    let Some(active) = impersonation_state(state, session).await else {
        // Either there is no record, or there is a *stale* one describing a user
        // this session no longer resolves as — an operator walked away without
        // reverting and somebody else logged in on the same session. Honoring
        // that record would hand the new user the operator's identity and role
        // with no credential, so drop it instead of restoring it.
        clear(session).await;
        return Err(crate::AutumnError::bad_request_msg("not impersonating"));
    };
    let real_id = active.impersonator_id;
    let effective_user_id = active.effective_user_id;

    if let Err(error) = crate::audit::write_from_state(
        state,
        AuditEvent::new(
            &real_id,
            END_AUDIT_ACTION,
            &effective_user_id,
            None,
            AuditStatus::Success,
        ),
    )
    .await
    {
        tracing::error!(
            target: "autumn.audit",
            impersonator_id = %real_id,
            target_id = %effective_user_id,
            %error,
            "failed to write the impersonation-end audit event; reverting anyway"
        );
    }

    session.insert(&auth_key, real_id.clone()).await;
    session.remove(IMPERSONATOR_SESSION_KEY).await;
    session.remove(IMPERSONATED_SESSION_KEY).await;
    session.remove(IMPERSONATION_SESSION_ID_KEY).await;
    restore_identity_keys(session).await;
    stash(session, IMPERSONATOR_ROLE_SESSION_KEY, ROLE_SESSION_KEY).await;
    // Put the operator's own step-up claim back exactly as it was, and discard
    // anything the impersonated session accrued — that freshness belonged to
    // the target's credential, not the operator's.
    stash(
        session,
        IMPERSONATOR_STEP_UP_SESSION_KEY,
        crate::step_up::STEP_UP_SESSION_KEY,
    )
    .await;
    session.rotate_id().await;

    // Same rule as `begin_impersonation`: seed, never clobber.
    if Current::actor().is_none() {
        Current::set_actor(real_id.clone());
    }

    Ok(ImpersonationState {
        impersonator_id: real_id,
        effective_user_id,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    const TEST_SID: &str = "sid-1";

    fn session_with(pairs: &[(&str, &str)]) -> Session {
        let data: HashMap<String, String> = pairs
            .iter()
            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
            .collect();
        Session::new_for_test(TEST_SID.to_owned(), data)
    }

    /// A session carrying a record bound to the current generation.
    fn impersonating_session(impersonator: &str, target: &str) -> Session {
        session_with(&[
            ("user_id", target),
            (IMPERSONATOR_SESSION_KEY, impersonator),
            (IMPERSONATED_SESSION_KEY, target),
            (IMPERSONATION_SESSION_ID_KEY, TEST_SID),
        ])
    }

    #[tokio::test]
    async fn audit_actor_prefers_the_impersonator_and_falls_back_to_the_user() {
        let impersonating = impersonating_session("admin", "target");
        assert_eq!(audit_actor_id(&impersonating, "target").await, "admin");

        let plain = session_with(&[("user_id", "u1")]);
        assert_eq!(audit_actor_id(&plain, "u1").await, "u1");
    }

    #[tokio::test]
    async fn a_stale_record_is_ignored_rather_than_misattributed() {
        // The record describes `target`, but the session now resolves as
        // `carol` — somebody logged in after an operator walked away without
        // reverting. Carol's writes are hers.
        let stale = session_with(&[
            ("user_id", "carol"),
            (IMPERSONATOR_SESSION_KEY, "admin"),
            (IMPERSONATED_SESSION_KEY, "target"),
            (IMPERSONATION_SESSION_ID_KEY, TEST_SID),
        ]);
        assert_eq!(audit_actor_id(&stale, "carol").await, "carol");
    }

    #[tokio::test]
    async fn a_record_bound_to_another_session_generation_is_ignored() {
        // Same user id on both sides, but the session was rotated since — the
        // shape a login leaves behind.
        let rotated = session_with(&[
            ("user_id", "target"),
            (IMPERSONATOR_SESSION_KEY, "admin"),
            (IMPERSONATED_SESSION_KEY, "target"),
            (IMPERSONATION_SESSION_ID_KEY, "a-previous-generation"),
        ]);
        assert_eq!(audit_actor_id(&rotated, "target").await, "target");
    }

    #[tokio::test]
    async fn a_half_written_record_is_ignored() {
        // Only the framework writes both keys together, so a session carrying
        // just one (an app poking at the reserved key by hand) is not a record.
        let half = session_with(&[("user_id", "target"), (IMPERSONATOR_SESSION_KEY, "admin")]);
        assert_eq!(audit_actor_id(&half, "target").await, "target");
    }

    #[tokio::test]
    async fn clear_drops_every_reserved_key() {
        let session = session_with(&[
            ("user_id", "target"),
            (IMPERSONATOR_SESSION_KEY, "admin"),
            (IMPERSONATED_SESSION_KEY, "target"),
            (IMPERSONATION_SESSION_ID_KEY, TEST_SID),
            (IMPERSONATOR_ROLE_SESSION_KEY, "admin"),
            (IMPERSONATOR_STEP_UP_SESSION_KEY, "123"),
        ]);
        clear(&session).await;
        for key in [
            IMPERSONATOR_SESSION_KEY,
            IMPERSONATED_SESSION_KEY,
            IMPERSONATION_SESSION_ID_KEY,
            IMPERSONATOR_ROLE_SESSION_KEY,
            IMPERSONATOR_STEP_UP_SESSION_KEY,
        ] {
            assert_eq!(session.get(key).await, None, "{key} must be cleared");
        }
        assert_eq!(
            session.get("user_id").await,
            Some("target".to_owned()),
            "clear() drops the record, not the session"
        );
    }

    #[tokio::test]
    async fn the_live_step_up_key_is_reserved() {
        // The swap stashes the operator's live step-up claim aside and then
        // writes the target id through the configured auth key. With
        // `[auth].session_key = "last_strong_auth_at"` those are the same
        // entry, and a numeric target id in the neighbourhood of current
        // epoch seconds would satisfy step-up with no reauthentication — so
        // the reserved-key check refuses the configuration instead.
        assert!(is_reserved_session_key(crate::step_up::STEP_UP_SESSION_KEY));
        assert_eq!(crate::step_up::STEP_UP_SESSION_KEY, "last_strong_auth_at");
        assert_eq!(RESERVED_SESSION_KEYS.len(), 8);
    }

    #[test]
    fn normalizing_a_target_trims_the_id() {
        assert_eq!(
            ImpersonationTarget::new("  root  ").normalized().user_id(),
            "root"
        );
        assert_eq!(
            ImpersonationTarget::new("u1")
                .with_role("member")
                .normalized()
                .role(),
            Some("member"),
            "normalizing preserves the trusted role decision"
        );
    }

    #[test]
    fn a_target_is_built_from_any_string_like_id() {
        assert_eq!(ImpersonationTarget::from("u1").user_id(), "u1");
        assert_eq!(ImpersonationTarget::from("u1".to_owned()).user_id(), "u1");
        assert_eq!(ImpersonationTarget::from(&"u1".to_owned()).user_id(), "u1");
        assert_eq!(ImpersonationTarget::new("u1").role(), None);
        assert_eq!(
            ImpersonationTarget::new("u1").with_role("member").role(),
            Some("member")
        );
    }

    #[tokio::test]
    async fn deny_all_refuses_and_allow_roles_matches_any_listed_role() {
        let session = session_with(&[("user_id", "admin-1"), ("role", "admin")]);
        let ctx = PolicyContext::from_session(&session, "user_id").await;
        let target = ImpersonationTarget::new("u9");

        assert!(
            !ImpersonationGate::deny_all()
                .policy()
                .can_impersonate(&ctx, &target)
                .await
        );
        assert!(
            ImpersonationGate::allow_roles(["support", "admin"])
                .policy()
                .can_impersonate(&ctx, &target)
                .await
        );
        assert!(
            !ImpersonationGate::allow_roles(["support"])
                .policy()
                .can_impersonate(&ctx, &target)
                .await
        );
    }

    #[tokio::test]
    async fn the_default_target_role_is_none_so_no_role_is_inherited() {
        let session = session_with(&[("user_id", "admin-1"), ("role", "admin")]);
        let ctx = PolicyContext::from_session(&session, "user_id").await;
        let target = ImpersonationTarget::new("u9");
        assert_eq!(
            ImpersonationGate::allow_roles(["admin"])
                .policy()
                .target_role(&ctx, &target)
                .await,
            None,
            "an impersonated session must never inherit the admin's role"
        );
    }
}