use crate::guard::Identity;
use crate::middleware::SessionHandle;
use rustlavel_core::{Error, Result};
const IMPERSONATOR_KEY: &str = "_impersonator";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Impersonator(String);
impl Impersonator {
pub fn new(id: impl Into<String>) -> Self {
Impersonator(id.into())
}
pub fn id(&self) -> &str {
&self.0
}
pub fn id_as<T: std::str::FromStr>(&self) -> Option<T> {
self.0.parse().ok()
}
}
impl std::fmt::Display for Impersonator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub struct Impersonation;
impl Impersonation {
pub fn start(session: &SessionHandle, target: impl Into<String>) -> Result<()> {
let real = session
.get_string(crate::guard::IDENTIFIER_KEY)
.ok_or_else(|| Error::msg("nobody is logged in, so there is nobody to impersonate as"))?;
if session.has(IMPERSONATOR_KEY) {
return Err(Error::msg(
"this session is already impersonating somebody; stop that first. \
Impersonation does not nest, so that the real operator is never in doubt.",
));
}
let target = target.into();
if target == real {
return Err(Error::msg("a user cannot impersonate themselves"));
}
session.regenerate();
session.put(IMPERSONATOR_KEY, real);
session.put(crate::guard::IDENTIFIER_KEY, target);
Ok(())
}
pub fn stop(session: &SessionHandle) -> Option<String> {
let real = session.get_string(IMPERSONATOR_KEY)?;
let was = session.get_string(crate::guard::IDENTIFIER_KEY);
session.regenerate();
session.forget(IMPERSONATOR_KEY);
session.put(crate::guard::IDENTIFIER_KEY, real);
was
}
pub fn is_impersonating(session: &SessionHandle) -> bool {
session.has(IMPERSONATOR_KEY)
}
pub fn impersonator(session: &SessionHandle) -> Option<Impersonator> {
session.get_string(IMPERSONATOR_KEY).map(Impersonator::new)
}
}
pub trait ImpersonationExt {
fn impersonator(&self) -> Option<&Impersonator>;
fn acting_user(&self) -> Option<String>;
}
impl ImpersonationExt for rustlavel_http::Request {
fn impersonator(&self) -> Option<&Impersonator> {
self.extension::<Impersonator>()
}
fn acting_user(&self) -> Option<String> {
match self.extension::<Impersonator>() {
Some(real) => Some(real.id().to_string()),
None => self.extension::<Identity>().map(|id| id.id().to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::guard::AuthExt;
use crate::key::AppKey;
use crate::middleware::{SessionExt, SessionManager};
use crate::store::MemoryStore;
use rustlavel_http::{Method, Request, Response, Router, TestClient};
fn client() -> TestClient {
let mut router = Router::new();
router.middleware(SessionManager::new(&AppKey::from_bytes([5u8; 32]), MemoryStore::new()));
router.get("/", |req: Request| async move {
let session = req.session();
match req.query("do") {
Some("login") => {
crate::Guard::new(session.clone()).login_using_id(req.query("as").unwrap_or("1"));
}
Some("start") => {
if let Err(error) = Impersonation::start(session, req.query("as").unwrap_or("2")) {
return Response::text(format!("error: {error}"));
}
}
Some("stop") => {
Impersonation::stop(session);
}
_ => {}
}
let identity = session.get_string(crate::guard::IDENTIFIER_KEY).unwrap_or_else(|| "-".into());
let real = Impersonation::impersonator(session).map(|i| i.id().to_string()).unwrap_or_else(|| "-".into());
Response::text(format!("{identity}|{real}"))
});
TestClient::new(router)
}
#[tokio::test]
async fn starting_swaps_the_identity_and_remembers_who_was_really_there() {
let client = client();
assert_eq!(client.get("/?do=login&as=7").await.body(), "7|-");
assert_eq!(client.get("/?do=start&as=42").await.body(), "42|7", "viewing 42, really 7");
}
#[tokio::test]
async fn stopping_puts_the_real_user_back() {
let client = client();
client.get("/?do=login&as=7").await;
client.get("/?do=start&as=42").await;
assert_eq!(client.get("/?do=stop").await.body(), "7|-");
assert_eq!(client.get("/").await.body(), "7|-", "and it stays put");
}
#[tokio::test]
async fn stopping_when_not_impersonating_does_nothing_and_does_not_fail() {
let client = client();
client.get("/?do=login&as=7").await;
assert_eq!(client.get("/?do=stop").await.body(), "7|-");
}
#[tokio::test]
async fn it_does_not_nest() {
let client = client();
client.get("/?do=login&as=7").await;
client.get("/?do=start&as=42").await;
let response = client.get("/?do=start&as=99").await;
assert!(response.body().contains("already impersonating"), "{}", response.body());
assert_eq!(client.get("/").await.body(), "42|7", "and the first one is untouched");
}
#[tokio::test]
async fn a_guest_cannot_impersonate() {
let response = client().get("/?do=start&as=42").await;
assert!(response.body().contains("nobody is logged in"), "{}", response.body());
}
#[tokio::test]
async fn a_user_cannot_impersonate_themselves() {
let client = client();
client.get("/?do=login&as=7").await;
let response = client.get("/?do=start&as=7").await;
assert!(response.body().contains("cannot impersonate themselves"), "{}", response.body());
}
#[tokio::test]
async fn the_session_id_changes_on_the_way_in_and_out() {
let client = client();
client.get("/?do=login&as=7").await;
let before = client.cookies().get("rustlavel_session").cloned();
client.get("/?do=start&as=42").await;
let during = client.cookies().get("rustlavel_session").cloned();
assert_ne!(before, during, "the id must change when the identity does");
client.get("/?do=stop").await;
let after = client.cookies().get("rustlavel_session").cloned();
assert_ne!(during, after, "and again on the way back");
}
#[test]
fn an_audit_log_records_the_operator_not_the_person_being_viewed() {
let plain = Request::new(Method::Get, "/");
assert_eq!(plain.acting_user(), None);
let mut logged_in = Request::new(Method::Get, "/");
logged_in.extend(Identity::new("7"));
assert_eq!(logged_in.acting_user().as_deref(), Some("7"));
let mut viewing = Request::new(Method::Get, "/");
viewing.extend(Identity::new("42"));
viewing.extend(Impersonator::new("7"));
assert_eq!(viewing.identity().map(|i| i.id().to_string()).as_deref(), Some("42"));
assert_eq!(
viewing.acting_user().as_deref(),
Some("7"),
"the administrator is accountable, not the user they were looking at"
);
}
}