Skip to main content

sofie_identity/impersonation/
mod.rs

1//! Signing in as a member, for a permitted principal of another area: each time recorded
2//! with its reason and raised as an event, the session it opens marked as such to the member
3//! and ended by itself after half an hour, and the impersonator signed back in at its end.
4
5use std::time::Duration;
6
7use sofie::core::Instant;
8use sofie::db::{Outcome, Plan, Resolve, Stamp, Uuid};
9use sofie::http::{
10    Area, CurrentSession, NeedsPermission, Permission, Permissions, RequestContext, Routes, Shipped,
11};
12use sofie::prelude::*;
13
14use crate::account::Account;
15use crate::membership::{self, held};
16use crate::signing_in::{SignedIn, sign_in};
17use crate::{AccountId, MemberArea, Unscoped, account_of};
18
19const READ: Permission = Permission::new("sofie_identity.impersonation.*.read");
20const CREATE: Permission = Permission::new("sofie_identity.impersonation.*.create");
21/// Long enough to look into a problem, short enough that a forgotten one ends soon.
22const LASTS: Duration = Duration::from_mins(30);
23const SHOWN: u64 = 50;
24const NOT_A_MEMBER: &str = "has no account that is a member there";
25const PERMITTED_ELSEWHERE: &str =
26    "is a member of the area impersonating it, which nobody impersonates";
27const NOT_IMPERSONATING: &str = "This session is no impersonation.";
28
29/// The audit record: who signed in as whom, where, why, and until when. The member owns
30/// it, so its own area reads and ends it.
31#[derive(Aggregate)]
32pub struct Impersonation {
33    meta: Meta,
34    #[owner]
35    account: AccountId,
36    impersonator: AccountId,
37    /// As it was then, as an audit record keeps it.
38    impersonator_email: Email,
39    area: String,
40    reason: String,
41    session: Uuid,
42    /// The impersonator's own session, which must still live for stopping to sign back in.
43    started_from: Option<Uuid>,
44    ends_at: Instant,
45    ended_at: Option<Instant>,
46    /// The member's permissions at the start, which the impersonator's covered; a request
47    /// finding the member holding another ends the impersonation, as it may reach beyond them.
48    granted: String,
49}
50
51const GRANTED_SEPARATOR: char = ' ';
52
53impl Impersonation {
54    pub(crate) fn end(&mut self, now: Instant) -> Result<()> {
55        self.ended_at = Some(now);
56        self.meta.raise(ImpersonationEnded {
57            impersonation: self.id().uuid(),
58        })
59    }
60
61    fn still_covers(&self, granted: &[String]) -> bool {
62        granted.iter().all(|permission| {
63            self.granted
64                .split(GRANTED_SEPARATOR)
65                .any(|held| held == permission)
66        })
67    }
68}
69
70/// Raised when an impersonation starts; an app keeps its own audit log by `#[on]`.
71#[derive(Event)]
72pub struct ImpersonationStarted {
73    pub impersonation: Uuid,
74    pub impersonator: Uuid,
75    pub member: Uuid,
76    pub area: String,
77    pub reason: String,
78}
79
80#[derive(Event)]
81pub struct ImpersonationEnded {
82    pub impersonation: Uuid,
83}
84
85/// In the permitted area `S`: the record of impersonations, and starting one as a member of `M`.
86pub fn routes<S, M>(routes: &mut Routes<S>)
87where
88    S: Area,
89    M: MemberArea,
90{
91    let describe: sofie::http::Describe =
92        |screens, key| screens.describe_form::<Starting>(key, "impersonate");
93    routes
94        .shipped(
95            "/impersonation",
96            "impersonation",
97            (include_str!("screen.html"), Some(describe)),
98            record,
99        )
100        .name("list_impersonations")
101        .permission(READ);
102    routes
103        .post("/impersonation", start::<S, M>)
104        .name("impersonate")
105        .permission(CREATE)
106        .then_redirect(M::HOME);
107}
108
109/// In the member area `M`: ending the impersonation and returning to `S`.
110pub fn stop_routes<M, S>(routes: &mut Routes<M>)
111where
112    M: MemberArea,
113    S: Area,
114{
115    routes
116        .post("/impersonation/stop", stop::<M, S>)
117        .name("stop_impersonating")
118        .then_redirect(&format!("{}/impersonation", S::PREFIX));
119}
120
121#[derive(ReadModel)]
122pub struct Record {
123    impersonations: Vec<Listed>,
124}
125
126#[derive(ReadModel)]
127struct Listed {
128    impersonator: Email,
129    member: Email,
130    area: String,
131    reason: String,
132    created_at: Option<Instant>,
133    ended_at: Option<Instant>,
134}
135
136#[derive(ReadModel)]
137#[read(from = Account)]
138struct Member {
139    id: AccountId,
140    email: Email,
141}
142
143async fn record(read: Read, clock: Clock) -> Result<Record> {
144    let now = clock.now();
145    let held = Impersonation::query()
146        .latest()
147        .limit(SHOWN)
148        .all(&read)
149        .await?;
150    let members = Member::query()
151        .where_(Account::ID.in_(held.iter().map(|impersonation| impersonation.account)))
152        .all(&read)
153        .await?;
154    let email_of = |account| {
155        members
156            .iter()
157            .find(|member| member.id == account)
158            .map(|member| member.email.clone())
159            .ok_or(Error::NotFound)
160    };
161    let impersonations = held
162        .into_iter()
163        .map(|impersonation| {
164            Ok(Listed {
165                member: email_of(impersonation.account)?,
166                created_at: impersonation.created_at(),
167                impersonator: impersonation.impersonator_email,
168                area: impersonation.area,
169                reason: impersonation.reason,
170                ended_at: impersonation
171                    .ended_at
172                    .or(Some(impersonation.ends_at).filter(|ends_at| *ends_at <= now)),
173            })
174        })
175        .collect::<Result<_>>()?;
176    Ok(Record { impersonations })
177}
178
179#[derive(Input)]
180pub struct Starting {
181    email: Email,
182    /// Kept in the record, so every impersonation says what it was for.
183    #[min(3)]
184    #[max(500)]
185    reason: String,
186}
187
188pub struct Started {
189    impersonation: Impersonation,
190    signed_in: SignedIn,
191}
192
193async fn start<S, M>(
194    principal: Principal<S>,
195    permissions: Permissions,
196    starting: Starting,
197    read: Read,
198    session: Session,
199    clock: Clock,
200) -> Result<Started>
201where
202    S: Area,
203    M: MemberArea,
204{
205    let reason = starting.reason.trim();
206    if reason.chars().count() < 3 {
207        return Err(Error::invalid(
208            "reason",
209            "must say what the impersonation is for",
210        ));
211    }
212    let impersonator = Account::stored(&read, account_of(&principal)).await?;
213    let member = Account::of_email(&read, starting.email)
214        .await?
215        .filter(|member| !member.is_closed())
216        .ok_or_else(|| Error::invalid("email", NOT_A_MEMBER))?;
217    if held(&read, member.id(), M::NAME).await?.is_none() {
218        return Err(Error::invalid("email", NOT_A_MEMBER));
219    }
220    if held(&read, member.id(), S::NAME).await?.is_some() {
221        return Err(Error::invalid("email", PERMITTED_ELSEWHERE));
222    }
223    let granted = refuse_unless_within(&read, &permissions, member.id(), M::NAME).await?;
224    let now = clock.now();
225    let ends_at = now.checked_add(LASTS)?;
226    let started_from = session.current();
227    let signed_in =
228        sign_in::<M>(&session, member.id(), &clock)?.impersonated(impersonator.email(), ends_at);
229    let current = signed_in.session();
230    let mut impersonation = Impersonation {
231        meta: Meta::new()?,
232        account: member.id(),
233        impersonator: impersonator.id(),
234        impersonator_email: impersonator.email().clone(),
235        area: M::NAME.to_owned(),
236        reason: reason.to_owned(),
237        session: current,
238        started_from,
239        ends_at,
240        ended_at: None,
241        granted: granted.join(&GRANTED_SEPARATOR.to_string()),
242    };
243    impersonation.meta.raise(ImpersonationStarted {
244        impersonation: impersonation.id().uuid(),
245        impersonator: impersonator.id().uuid(),
246        member: member.id().uuid(),
247        area: M::NAME.to_owned(),
248        reason: impersonation.reason.clone(),
249    })?;
250    Ok(Started {
251        impersonation,
252        signed_in,
253    })
254}
255
256/// The rule role grants follow: nobody acts beyond its own permissions, here as another.
257async fn refuse_unless_within(
258    read: &Read,
259    impersonator: &Permissions,
260    member: AccountId,
261    area: &str,
262) -> Result<Vec<String>> {
263    let granted = membership::roles::granted(read, area, member).await?;
264    match granted
265        .iter()
266        .find(|permission| !impersonator.can_named(permission))
267    {
268        Some(beyond) => Err(Error::invalid(
269            "email",
270            format!(
271                "holds `{beyond}`, which your own permissions do not cover, so you cannot impersonate it"
272            ),
273        )),
274        None => Ok(granted),
275    }
276}
277
278impl Outcome for Started {
279    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
280        self.impersonation.plan(plan)?;
281        self.signed_in.plan(plan)
282    }
283
284    fn committed(&mut self, stamp: Stamp) {
285        self.impersonation.committed(stamp);
286        self.signed_in.committed(stamp);
287    }
288}
289
290async fn stop<M, S>(
291    _member: Principal<M>,
292    read: Read,
293    unscoped: Unscoped,
294    session: Session,
295    clock: Clock,
296) -> Result<()>
297where
298    M: MemberArea,
299    S: Area,
300{
301    let mut impersonation = of_session(&read, session.current())
302        .await?
303        .ok_or_else(|| Error::Rule(NOT_IMPERSONATING.to_owned()))?;
304    impersonation.end(clock.now())?;
305    let sessions = crate::sessions::ending_current(&read, &session, &clock).await?;
306    let signed_in = if returns::<S>(&unscoped.0.read(), &impersonation).await? {
307        Some(sign_in::<S>(&session, impersonation.impersonator, &clock)?)
308    } else {
309        session.sign_out();
310        None
311    };
312    unscoped
313        .0
314        .commit(&mut (impersonation, sessions, signed_in))
315        .await
316}
317
318/// The member's session outlives a password reset or "sign out everywhere" of the
319/// impersonator's account, so only the impersonator's own session still live signs it back in.
320async fn returns<S: Area>(read: &Read, impersonation: &Impersonation) -> Result<bool> {
321    let Some(started_from) = impersonation.started_from else {
322        return Ok(false);
323    };
324    let impersonator = impersonation.impersonator;
325    if crate::sessions::account_of(read, started_from).await? != Some(impersonator) {
326        return Ok(false);
327    }
328    Ok(held(read, impersonator, S::NAME).await?.is_some())
329}
330
331#[cfg(feature = "account-data")]
332pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
333    let impersonations = Impersonation::query()
334        .where_(Impersonation::ACCOUNT.eq(account))
335        .all(read)
336        .await?;
337    Ok(sofie::serde_json::json!(
338        impersonations
339            .iter()
340            .map(|impersonation| sofie::serde_json::json!({
341                "by": impersonation.impersonator_email.as_str(),
342                "area": impersonation.area,
343                "reason": impersonation.reason,
344                "ends_at": impersonation.ends_at,
345                "ended_at": impersonation.ended_at,
346            }))
347            .collect::<Vec<_>>()
348    ))
349}
350
351/// Each impersonation in one of `sessions`, ended on the record, for whatever ended them.
352pub(crate) async fn ending_in_sessions(
353    read: &Read,
354    sessions: &[Uuid],
355    now: Instant,
356) -> Result<Vec<Impersonation>> {
357    if sessions.is_empty() {
358        return Ok(Vec::new());
359    }
360    let mut open = Impersonation::query()
361        .where_(Impersonation::SESSION.in_(sessions.to_vec()))
362        .where_(Impersonation::ENDED_AT.is_null())
363        .all(read)
364        .await?;
365    for impersonation in &mut open {
366        impersonation.end(now)?;
367    }
368    Ok(open)
369}
370
371pub(crate) async fn sessions_started_by(read: &Read, impersonator: AccountId) -> Result<Vec<Uuid>> {
372    let open = Impersonation::query()
373        .where_(Impersonation::IMPERSONATOR.eq(impersonator))
374        .where_(Impersonation::ENDED_AT.is_null())
375        .all(read)
376        .await?;
377    Ok(open
378        .into_iter()
379        .map(|impersonation| impersonation.session)
380        .collect())
381}
382
383async fn of_session(read: &Read, session: Option<Uuid>) -> Result<Option<Impersonation>> {
384    let Some(session) = session else {
385        return Ok(None);
386    };
387    let found = Impersonation::query()
388        .where_(Impersonation::SESSION.eq(session))
389        .first(read)
390        .await?;
391    Ok(found.filter(|impersonation| impersonation.ended_at.is_none()))
392}
393
394pub(crate) enum Checked {
395    Absent,
396    By(Uuid),
397    Beyond(Box<Impersonation>),
398}
399
400/// Read on each request of an impersonated session only, so no other pays for it.
401pub(crate) async fn checked(read: &Read, session: Uuid) -> Result<Checked> {
402    let Some(impersonation) = of_session(read, Some(session)).await? else {
403        return Ok(Checked::Absent);
404    };
405    let granted =
406        membership::roles::granted(read, &impersonation.area, impersonation.account).await?;
407    if impersonation.still_covers(&granted) {
408        return Ok(Checked::By(impersonation.impersonator.uuid()));
409    }
410    Ok(Checked::Beyond(Box::new(impersonation)))
411}
412
413/// For a member area's screens: who is impersonating its member, if anyone, so a page can
414/// show it and offer `/impersonation/stop`.
415pub struct Impersonating(Option<String>);
416
417impl Impersonating {
418    #[must_use]
419    pub fn by(&self) -> Option<&str> {
420        self.0.as_deref()
421    }
422}
423
424impl<A: Area, M> Resolve<RequestContext<A, M>> for Impersonating {
425    async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
426        let current = CurrentSession::resolve(context).await?;
427        let read = Read::resolve(context).await?;
428        let impersonation = of_session(&read, current.id()).await?;
429        Ok(Self(impersonation.map(|impersonation| {
430            impersonation.impersonator_email.as_str().to_owned()
431        })))
432    }
433}