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;
pub const IMPERSONATOR_SESSION_KEY: &str = "impersonator_id";
pub const IMPERSONATED_SESSION_KEY: &str = "impersonated_id";
pub const IMPERSONATOR_ROLE_SESSION_KEY: &str = "impersonator_role";
pub const IMPERSONATION_SESSION_ID_KEY: &str = "impersonation_session_id";
pub const IMPERSONATOR_STEP_UP_SESSION_KEY: &str = "impersonator_last_strong_auth_at";
pub const IMPERSONATOR_IDENTITY_SESSION_KEY: &str = "impersonator_identity_keys";
const ROLE_SESSION_KEY: &str = "role";
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,
];
#[must_use]
pub fn is_reserved_session_key(key: &str) -> bool {
RESERVED_SESSION_KEYS.contains(&key)
}
pub const BEGIN_AUDIT_ACTION: &str = "auth.impersonation.begin";
pub const END_AUDIT_ACTION: &str = "auth.impersonation.end";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImpersonationTarget {
user_id: String,
role: Option<String>,
}
impl ImpersonationTarget {
#[must_use]
pub fn new(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
role: None,
}
}
#[must_use]
pub fn with_role(mut self, role: impl Into<String>) -> Self {
self.role = Some(role.into());
self
}
#[must_use]
pub fn user_id(&self) -> &str {
&self.user_id
}
#[must_use]
pub fn role(&self) -> Option<&str> {
self.role.as_deref()
}
#[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)
}
}
pub trait ImpersonationPolicy: Send + Sync + 'static {
fn can_impersonate<'a>(
&'a self,
ctx: &'a PolicyContext,
target: &'a ImpersonationTarget,
) -> BoxFuture<'a, bool>;
fn target_role<'a>(
&'a self,
_ctx: &'a PolicyContext,
_target: &'a ImpersonationTarget,
) -> BoxFuture<'a, Option<String>> {
Box::pin(async { None })
}
fn target_session_values<'a>(
&'a self,
_ctx: &'a PolicyContext,
_target: &'a ImpersonationTarget,
) -> BoxFuture<'a, Vec<(String, Option<String>)>> {
Box::pin(async { Vec::new() })
}
}
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 })
}
}
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) })
}
}
#[derive(Clone)]
pub struct ImpersonationGate(Arc<dyn ImpersonationPolicy>);
impl ImpersonationGate {
#[must_use]
pub fn deny_all() -> Self {
Self(Arc::new(DenyAll))
}
#[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(),
)))
}
#[must_use]
pub fn custom<P: ImpersonationPolicy>(policy: P) -> Self {
Self(Arc::new(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")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ImpersonationState {
pub impersonator_id: String,
pub effective_user_id: String,
}
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))
}
pub async fn impersonator_id(state: &AppState, session: &Session) -> Option<String> {
impersonation_state(state, session)
.await
.map(|active| active.impersonator_id)
}
pub async fn is_impersonating(state: &AppState, session: &Session) -> bool {
impersonation_state(state, session).await.is_some()
}
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,
})
}
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;
}
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
}
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(),
}
}
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)
}
#[derive(Debug, Clone, Default)]
pub struct Impersonation(Option<ImpersonationState>);
impl Impersonation {
#[must_use]
pub const fn state(&self) -> Option<&ImpersonationState> {
self.0.as_ref()
}
#[must_use]
pub fn into_state(self) -> Option<ImpersonationState> {
self.0
}
#[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))
}
}
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;
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;
session.rotate_id().await;
session
.insert(IMPERSONATION_SESSION_ID_KEY, session.id().await)
.await;
}
async fn stash_identity_keys(session: &Session, extra: &[(String, Option<String>)]) {
drop_identity_keys(session).await;
if extra.is_empty() {
return;
}
let mut saved = serde_json::Map::new();
for (key, value) in extra {
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;
}
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;
}
}
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 {
if is_reserved_session_key(&key) {
continue;
}
set_or_remove(session, &key, value.as_str()).await;
}
}
async fn stash(session: &Session, from: &str, to: &str) {
let value = session.remove(from).await;
set_or_remove(session, to, value.as_deref()).await;
}
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;
}
}
}
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();
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",
));
}
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))
}
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"
))
}
pub async fn begin_impersonation(
state: &AppState,
session: &Session,
target: impl Into<ImpersonationTarget>,
) -> crate::AutumnResult<ImpersonationState> {
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,
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",
));
}
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"
)));
}
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",
));
}
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}"
))
})?;
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;
if Current::actor().is_none() {
Current::set_actor(real_id.clone());
}
Ok(ImpersonationState {
impersonator_id: real_id,
effective_user_id: target_id.to_owned(),
})
}
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 {
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;
stash(
session,
IMPERSONATOR_STEP_UP_SESSION_KEY,
crate::step_up::STEP_UP_SESSION_KEY,
)
.await;
session.rotate_id().await;
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)
}
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() {
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() {
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() {
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() {
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"
);
}
}