use std::time::Duration;
use sofie::core::Instant;
use sofie::db::{Context, Db, Outcome, Plan, Resolve, Stamp, Uuid};
use sofie::http::{
Area, CurrentSession, NeedsPermission, Permission, Permissions, RequestContext, Routes, Shipped,
};
use sofie::prelude::*;
use crate::account::Account;
use crate::membership::{self, held};
use crate::signing_in::{SignedIn, sign_in};
use crate::{AccountId, MemberArea, account_of};
const READ: Permission = Permission::new("sofie_identity.impersonation.*.read");
const CREATE: Permission = Permission::new("sofie_identity.impersonation.*.create");
const LASTS: Duration = Duration::from_mins(30);
const SHOWN: u64 = 50;
const NOT_A_MEMBER: &str = "has no account that is a member there";
const PERMITTED_ELSEWHERE: &str =
"is a member of the area impersonating it, which nobody impersonates";
const NOT_IMPERSONATING: &str = "This session is no impersonation.";
#[derive(Aggregate)]
pub struct Impersonation {
meta: Meta,
#[owner]
account: AccountId,
impersonator: AccountId,
impersonator_email: Email,
area: String,
reason: String,
session: Uuid,
ends_at: Instant,
ended_at: Option<Instant>,
granted: String,
}
const GRANTED_SEPARATOR: char = ' ';
impl Impersonation {
pub(crate) fn end(&mut self, now: Instant) -> Result<()> {
self.ended_at = Some(now);
self.meta.raise(ImpersonationEnded {
impersonation: self.id().uuid(),
})
}
fn still_covers(&self, granted: &[String]) -> bool {
granted.iter().all(|permission| {
self.granted
.split(GRANTED_SEPARATOR)
.any(|held| held == permission)
})
}
}
#[derive(Event)]
pub struct ImpersonationStarted {
pub impersonation: Uuid,
pub impersonator: Uuid,
pub member: Uuid,
pub area: String,
pub reason: String,
}
#[derive(Event)]
pub struct ImpersonationEnded {
pub impersonation: Uuid,
}
pub fn routes<S, M>(routes: &mut Routes<S>)
where
S: Area,
M: MemberArea,
{
let describe: sofie::http::Describe =
|screens, key| screens.describe_form::<Starting>(key, "impersonate");
routes
.shipped(
"/impersonation",
"impersonation",
(include_str!("screen.html"), Some(describe)),
record,
)
.name("list_impersonations")
.permission(READ);
routes
.post("/impersonation", start::<S, M>)
.name("impersonate")
.permission(CREATE)
.then_redirect(M::HOME);
}
pub fn stop_routes<M, S>(routes: &mut Routes<M>)
where
M: MemberArea,
S: Area,
{
routes
.post("/impersonation/stop", stop::<M, S>)
.name("stop_impersonating")
.then_redirect(&format!("{}/impersonation", S::PREFIX));
}
#[derive(ReadModel)]
pub struct Record {
impersonations: Vec<Listed>,
}
#[derive(ReadModel)]
struct Listed {
impersonator: Email,
member: Email,
area: String,
reason: String,
created_at: Option<Instant>,
ended_at: Option<Instant>,
}
#[derive(ReadModel)]
#[read(from = Account)]
struct Member {
id: AccountId,
email: Email,
}
async fn record(read: Read, clock: Clock) -> Result<Record> {
let now = clock.now();
let held = Impersonation::query()
.latest()
.limit(SHOWN)
.all(&read)
.await?;
let members = Member::query()
.where_(Account::ID.in_(held.iter().map(|impersonation| impersonation.account)))
.all(&read)
.await?;
let email_of = |account| {
members
.iter()
.find(|member| member.id == account)
.map(|member| member.email.clone())
.ok_or(Error::NotFound)
};
let impersonations = held
.into_iter()
.map(|impersonation| {
Ok(Listed {
member: email_of(impersonation.account)?,
created_at: impersonation.created_at(),
impersonator: impersonation.impersonator_email,
area: impersonation.area,
reason: impersonation.reason,
ended_at: impersonation
.ended_at
.or(Some(impersonation.ends_at).filter(|ends_at| *ends_at <= now)),
})
})
.collect::<Result<_>>()?;
Ok(Record { impersonations })
}
#[derive(Input)]
pub struct Starting {
email: Email,
#[min(3)]
#[max(500)]
reason: String,
}
pub struct Started {
impersonation: Impersonation,
signed_in: SignedIn,
}
async fn start<S, M>(
principal: Principal<S>,
permissions: Permissions,
starting: Starting,
read: Read,
session: Session,
clock: Clock,
) -> Result<Started>
where
S: Area,
M: MemberArea,
{
let reason = starting.reason.trim();
if reason.chars().count() < 3 {
return Err(Error::invalid(
"reason",
"must say what the impersonation is for",
));
}
let impersonator = Account::stored(&read, account_of(&principal)).await?;
let member = Account::of_email(&read, starting.email)
.await?
.filter(|member| !member.is_closed())
.ok_or_else(|| Error::invalid("email", NOT_A_MEMBER))?;
if held(&read, member.id(), M::NAME).await?.is_none() {
return Err(Error::invalid("email", NOT_A_MEMBER));
}
if held(&read, member.id(), S::NAME).await?.is_some() {
return Err(Error::invalid("email", PERMITTED_ELSEWHERE));
}
let granted = refuse_unless_within(&read, &permissions, member.id(), M::NAME).await?;
let now = clock.now();
let ends_at = now.checked_add(LASTS)?;
let signed_in =
sign_in::<M>(&session, member.id(), &clock)?.impersonated(impersonator.email(), ends_at);
let current = signed_in.session();
let mut impersonation = Impersonation {
meta: Meta::new()?,
account: member.id(),
impersonator: impersonator.id(),
impersonator_email: impersonator.email().clone(),
area: M::NAME.to_owned(),
reason: reason.to_owned(),
session: current,
ends_at,
ended_at: None,
granted: granted.join(&GRANTED_SEPARATOR.to_string()),
};
impersonation.meta.raise(ImpersonationStarted {
impersonation: impersonation.id().uuid(),
impersonator: impersonator.id().uuid(),
member: member.id().uuid(),
area: M::NAME.to_owned(),
reason: impersonation.reason.clone(),
})?;
Ok(Started {
impersonation,
signed_in,
})
}
async fn refuse_unless_within(
read: &Read,
impersonator: &Permissions,
member: AccountId,
area: &str,
) -> Result<Vec<String>> {
let granted = membership::roles::granted(read, area, member).await?;
match granted
.iter()
.find(|permission| !impersonator.can_named(permission))
{
Some(beyond) => Err(Error::invalid(
"email",
format!(
"holds `{beyond}`, which your own permissions do not cover, so you cannot impersonate it"
),
)),
None => Ok(granted),
}
}
impl Outcome for Started {
fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
self.impersonation.plan(plan)?;
self.signed_in.plan(plan)
}
fn committed(&mut self, stamp: Stamp) {
self.impersonation.committed(stamp);
self.signed_in.committed(stamp);
}
}
async fn stop<M, S>(
_member: Principal<M>,
read: Read,
unscoped: Unscoped,
session: Session,
clock: Clock,
) -> Result<()>
where
M: MemberArea,
S: Area,
{
let mut impersonation = of_session(&read, session.current())
.await?
.ok_or_else(|| Error::Rule(NOT_IMPERSONATING.to_owned()))?;
impersonation.end(clock.now())?;
let sessions = crate::sessions::ending_current(&read, &session, &clock).await?;
let impersonator = impersonation.impersonator;
membership::require(&unscoped.0.read(), impersonator, S::NAME).await?;
let signed_in = sign_in::<S>(&session, impersonator, &clock)?;
unscoped
.0
.commit(&mut (impersonation, sessions, signed_in))
.await
}
#[cfg(feature = "account-data")]
pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
let impersonations = Impersonation::query()
.where_(Impersonation::ACCOUNT.eq(account))
.all(read)
.await?;
Ok(sofie::serde_json::json!(
impersonations
.iter()
.map(|impersonation| sofie::serde_json::json!({
"by": impersonation.impersonator_email.as_str(),
"area": impersonation.area,
"reason": impersonation.reason,
"ends_at": impersonation.ends_at,
"ended_at": impersonation.ended_at,
}))
.collect::<Vec<_>>()
))
}
pub(crate) async fn ending_in_sessions(
read: &Read,
sessions: &[Uuid],
now: Instant,
) -> Result<Vec<Impersonation>> {
if sessions.is_empty() {
return Ok(Vec::new());
}
let mut open = Impersonation::query()
.where_(Impersonation::SESSION.in_(sessions.to_vec()))
.where_(Impersonation::ENDED_AT.is_null())
.all(read)
.await?;
for impersonation in &mut open {
impersonation.end(now)?;
}
Ok(open)
}
pub(crate) struct Unscoped(Db);
impl<C: Context> Resolve<C> for Unscoped {
fn resolve(context: &mut C) -> impl Future<Output = Result<Self>> + Send {
std::future::ready(Ok(Self(context.db().clone())))
}
}
async fn of_session(read: &Read, session: Option<Uuid>) -> Result<Option<Impersonation>> {
let Some(session) = session else {
return Ok(None);
};
let found = Impersonation::query()
.where_(Impersonation::SESSION.eq(session))
.first(read)
.await?;
Ok(found.filter(|impersonation| impersonation.ended_at.is_none()))
}
pub(crate) enum Checked {
Absent,
By(Uuid),
Beyond(Box<Impersonation>),
}
pub(crate) async fn checked(read: &Read, session: Uuid) -> Result<Checked> {
let Some(impersonation) = of_session(read, Some(session)).await? else {
return Ok(Checked::Absent);
};
let granted =
membership::roles::granted(read, &impersonation.area, impersonation.account).await?;
if impersonation.still_covers(&granted) {
return Ok(Checked::By(impersonation.impersonator.uuid()));
}
Ok(Checked::Beyond(Box::new(impersonation)))
}
pub struct Impersonating(Option<String>);
impl Impersonating {
#[must_use]
pub fn by(&self) -> Option<&str> {
self.0.as_deref()
}
}
impl<A: Area, M> Resolve<RequestContext<A, M>> for Impersonating {
async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
let current = CurrentSession::resolve(context).await?;
let read = Read::resolve(context).await?;
let impersonation = of_session(&read, current.id()).await?;
Ok(Self(impersonation.map(|impersonation| {
impersonation.impersonator_email.as_str().to_owned()
})))
}
}