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::{AccountArea, 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    impersonator_area: String,
50    /// Likewise the impersonator's at the start: a request finding one gone ends it, as what
51    /// is left may no longer cover the member's.
52    impersonator_granted: String,
53}
54
55const GRANTED_SEPARATOR: char = ' ';
56
57impl Impersonation {
58    pub(crate) fn end(&mut self, now: Instant) -> Result<()> {
59        self.ended_at = Some(now);
60        self.meta.raise(ImpersonationEnded {
61            impersonation: self.id().uuid(),
62        })
63    }
64
65    fn still_covers(&self, member_holds: &[String], impersonator_holds: &[String]) -> bool {
66        let member_within = member_holds
67            .iter()
68            .all(|permission| listed(&self.granted).any(|granted| granted == permission));
69        let impersonator_kept = listed(&self.impersonator_granted)
70            .all(|granted| impersonator_holds.iter().any(|held| held == granted));
71        member_within && impersonator_kept
72    }
73}
74
75fn listed(granted: &str) -> impl Iterator<Item = &str> {
76    granted
77        .split(GRANTED_SEPARATOR)
78        .filter(|permission| !permission.is_empty())
79}
80
81fn joined(granted: &[String]) -> String {
82    granted.join(&GRANTED_SEPARATOR.to_string())
83}
84
85/// Raised when an impersonation starts; an app keeps its own audit log by `#[on]`.
86#[derive(Event)]
87pub struct ImpersonationStarted {
88    pub impersonation: Uuid,
89    pub impersonator: Uuid,
90    pub member: Uuid,
91    pub area: String,
92    pub reason: String,
93}
94
95#[derive(Event)]
96pub struct ImpersonationEnded {
97    pub impersonation: Uuid,
98}
99
100/// In the permitted area `S`: the record of impersonations, and starting one as a member of `M`.
101pub fn routes<S, M>(routes: &mut Routes<S>)
102where
103    S: AccountArea,
104    M: MemberArea,
105{
106    let describe: sofie::http::Describe =
107        |screens, key| screens.describe_form::<Starting>(key, "impersonate");
108    routes
109        .shipped(
110            "/impersonation",
111            "impersonation",
112            (
113                include_str!("screen.html"),
114                include_bytes!(concat!(
115                    env!("OUT_DIR"),
116                    "/shipped/impersonation/screen.sft"
117                )),
118                Some(describe),
119            ),
120            record,
121        )
122        .name("list_impersonations")
123        .permission(READ);
124    routes
125        .post("/impersonation", start::<S, M>)
126        .name("impersonate")
127        .permission(CREATE)
128        .then_redirect(M::HOME);
129}
130
131/// In the member area `M`: ending the impersonation and returning to `S`.
132pub fn stop_routes<M, S>(routes: &mut Routes<M>)
133where
134    M: MemberArea,
135    S: Area,
136{
137    routes
138        .post("/impersonation/stop", stop::<M, S>)
139        .name("stop_impersonating")
140        .then_redirect(&format!("{}/impersonation", S::PREFIX));
141}
142
143#[derive(ReadModel)]
144pub struct Record {
145    impersonations: Vec<Listed>,
146}
147
148#[derive(ReadModel)]
149struct Listed {
150    impersonator: Email,
151    member: Email,
152    area: String,
153    reason: String,
154    created_at: Option<Instant>,
155    ended_at: Option<Instant>,
156}
157
158#[derive(ReadModel)]
159#[read(from = Account)]
160struct Member {
161    id: AccountId,
162    email: Email,
163}
164
165async fn record(read: Read, clock: Clock) -> Result<Record> {
166    let now = clock.now();
167    let held = Impersonation::query()
168        .latest()
169        .limit(SHOWN)
170        .all(&read)
171        .await?;
172    let members = Member::query()
173        .where_(Account::ID.in_(held.iter().map(|impersonation| impersonation.account)))
174        .all(&read)
175        .await?;
176    let email_of = |account| {
177        members
178            .iter()
179            .find(|member| member.id == account)
180            .map(|member| member.email.clone())
181            .ok_or(Error::NotFound)
182    };
183    let impersonations = held
184        .into_iter()
185        .map(|impersonation| {
186            Ok(Listed {
187                member: email_of(impersonation.account)?,
188                created_at: impersonation.created_at(),
189                impersonator: impersonation.impersonator_email,
190                area: impersonation.area,
191                reason: impersonation.reason,
192                ended_at: impersonation
193                    .ended_at
194                    .or(Some(impersonation.ends_at).filter(|ends_at| *ends_at <= now)),
195            })
196        })
197        .collect::<Result<_>>()?;
198    Ok(Record { impersonations })
199}
200
201#[derive(Input)]
202pub struct Starting {
203    email: Email,
204    /// Kept in the record, so every impersonation says what it was for.
205    #[min(3)]
206    #[max(500)]
207    reason: String,
208}
209
210pub struct Started {
211    impersonation: Impersonation,
212    signed_in: SignedIn,
213}
214
215async fn start<S, M>(
216    principal: Principal<S>,
217    permissions: Permissions,
218    starting: Starting,
219    read: Read,
220    session: Session,
221    clock: Clock,
222) -> Result<Started>
223where
224    S: AccountArea,
225    M: MemberArea,
226{
227    let reason = starting.reason.trim();
228    if reason.chars().count() < 3 {
229        return Err(Error::invalid(
230            "reason",
231            "must say what the impersonation is for",
232        ));
233    }
234    let impersonator = Account::stored(&read, account_of(&principal)).await?;
235    let member = Account::of_email(&read, starting.email)
236        .await?
237        .filter(|member| !member.is_closed())
238        .ok_or_else(|| Error::invalid("email", NOT_A_MEMBER))?;
239    if held(&read, member.id(), M::NAME).await?.is_none() {
240        return Err(Error::invalid("email", NOT_A_MEMBER));
241    }
242    if held(&read, member.id(), S::NAME).await?.is_some() {
243        return Err(Error::invalid("email", PERMITTED_ELSEWHERE));
244    }
245    let granted = refuse_unless_within(&read, &permissions, member.id(), M::NAME).await?;
246    let impersonator_granted =
247        membership::roles::granted(&read, S::NAME, impersonator.id()).await?;
248    let now = clock.now();
249    let ends_at = now.checked_add(LASTS)?;
250    let started_from = session.current();
251    let signed_in =
252        sign_in::<M>(&session, member.id(), &clock)?.impersonated(impersonator.email(), ends_at);
253    let current = signed_in.session();
254    let mut impersonation = Impersonation {
255        meta: Meta::new()?,
256        account: member.id(),
257        impersonator: impersonator.id(),
258        impersonator_email: impersonator.email().clone(),
259        area: M::NAME.to_owned(),
260        reason: reason.to_owned(),
261        session: current,
262        started_from,
263        ends_at,
264        ended_at: None,
265        granted: joined(&granted),
266        impersonator_area: S::NAME.to_owned(),
267        impersonator_granted: joined(&impersonator_granted),
268    };
269    impersonation.meta.raise(ImpersonationStarted {
270        impersonation: impersonation.id().uuid(),
271        impersonator: impersonator.id().uuid(),
272        member: member.id().uuid(),
273        area: M::NAME.to_owned(),
274        reason: impersonation.reason.clone(),
275    })?;
276    Ok(Started {
277        impersonation,
278        signed_in,
279    })
280}
281
282/// The rule role grants follow: nobody acts beyond its own permissions, here as another.
283async fn refuse_unless_within(
284    read: &Read,
285    impersonator: &Permissions,
286    member: AccountId,
287    area: &str,
288) -> Result<Vec<String>> {
289    let granted = membership::roles::granted(read, area, member).await?;
290    match granted
291        .iter()
292        .find(|permission| !impersonator.can_named(permission))
293    {
294        Some(beyond) => Err(Error::invalid(
295            "email",
296            format!(
297                "holds `{beyond}`, which your own permissions do not cover, so you cannot impersonate it"
298            ),
299        )),
300        None => Ok(granted),
301    }
302}
303
304impl Outcome for Started {
305    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
306        self.impersonation.plan(plan)?;
307        self.signed_in.plan(plan)
308    }
309
310    fn committed(&mut self, stamp: Stamp) {
311        self.impersonation.committed(stamp);
312        self.signed_in.committed(stamp);
313    }
314}
315
316async fn stop<M, S>(
317    _member: Principal<M>,
318    read: Read,
319    unscoped: Unscoped,
320    session: Session,
321    clock: Clock,
322) -> Result<()>
323where
324    M: MemberArea,
325    S: Area,
326{
327    let mut impersonation = of_session(&read, session.current())
328        .await?
329        .ok_or_else(|| Error::Rule(NOT_IMPERSONATING.to_owned()))?;
330    impersonation.end(clock.now())?;
331    let sessions = crate::sessions::ending_current(&read, &session, &clock).await?;
332    match returning::<S>(&unscoped.0.read(), &impersonation).await? {
333        Some(resumed) => crate::signing_in::resume::<S>(&session, &resumed),
334        None => session.sign_out(),
335    }
336    unscoped.0.commit(&mut (impersonation, sessions)).await
337}
338
339/// The member's session outlives a password reset or "sign out everywhere" of the
340/// impersonator's account, so only the impersonator's own session, still live, is signed
341/// back in: never a new one, which would pass for a recent sign-in.
342async fn returning<S: Area>(
343    read: &Read,
344    impersonation: &Impersonation,
345) -> Result<Option<crate::sessions::Resumable>> {
346    let Some(started_from) = impersonation.started_from else {
347        return Ok(None);
348    };
349    let impersonator = impersonation.impersonator;
350    let Some(resumed) = crate::sessions::recorded(read, started_from)
351        .await?
352        .and_then(|record| record.resumable(impersonator, S::NAME))
353    else {
354        return Ok(None);
355    };
356    Ok(held(read, impersonator, S::NAME).await?.map(|_| resumed))
357}
358
359#[cfg(feature = "account-data")]
360pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
361    let impersonations = Impersonation::query()
362        .where_(Impersonation::ACCOUNT.eq(account))
363        .all(read)
364        .await?;
365    Ok(sofie::serde_json::json!(
366        impersonations
367            .iter()
368            .map(|impersonation| sofie::serde_json::json!({
369                "by": impersonation.impersonator_email.as_str(),
370                "area": impersonation.area,
371                "reason": impersonation.reason,
372                "ends_at": impersonation.ends_at,
373                "ended_at": impersonation.ended_at,
374            }))
375            .collect::<Vec<_>>()
376    ))
377}
378
379/// Each impersonation in one of `sessions`, ended on the record, for whatever ended them.
380pub(crate) async fn ending_in_sessions(
381    read: &Read,
382    sessions: &[Uuid],
383    now: Instant,
384) -> Result<Vec<Impersonation>> {
385    if sessions.is_empty() {
386        return Ok(Vec::new());
387    }
388    let mut open = Impersonation::query()
389        .where_(Impersonation::SESSION.in_(sessions.to_vec()))
390        .where_(Impersonation::ENDED_AT.is_null())
391        .all(read)
392        .await?;
393    for impersonation in &mut open {
394        impersonation.end(now)?;
395    }
396    Ok(open)
397}
398
399pub(crate) async fn sessions_started_by(read: &Read, impersonator: AccountId) -> Result<Vec<Uuid>> {
400    let open = Impersonation::query()
401        .where_(Impersonation::IMPERSONATOR.eq(impersonator))
402        .where_(Impersonation::ENDED_AT.is_null())
403        .all(read)
404        .await?;
405    Ok(open
406        .into_iter()
407        .map(|impersonation| impersonation.session)
408        .collect())
409}
410
411/// One recorded without the session it started from may have started from one of them.
412pub(crate) async fn sessions_started_from(
413    read: &Read,
414    impersonator: AccountId,
415    from: &[Uuid],
416) -> Result<Vec<Uuid>> {
417    let open = Impersonation::query()
418        .where_(Impersonation::IMPERSONATOR.eq(impersonator))
419        .where_(Impersonation::ENDED_AT.is_null())
420        .all(read)
421        .await?;
422    Ok(open
423        .into_iter()
424        .filter(|impersonation| {
425            impersonation
426                .started_from
427                .is_none_or(|started| from.contains(&started))
428        })
429        .map(|impersonation| impersonation.session)
430        .collect())
431}
432
433async fn of_session(read: &Read, session: Option<Uuid>) -> Result<Option<Impersonation>> {
434    let Some(session) = session else {
435        return Ok(None);
436    };
437    let found = Impersonation::query()
438        .where_(Impersonation::SESSION.eq(session))
439        .first(read)
440        .await?;
441    Ok(found.filter(|impersonation| impersonation.ended_at.is_none()))
442}
443
444pub(crate) enum Checked {
445    Absent,
446    By(Uuid),
447    Beyond(Box<Impersonation>),
448}
449
450/// Read on each request of an impersonated session only, so no other pays for it.
451pub(crate) async fn checked(read: &Read, session: Uuid) -> Result<Checked> {
452    let Some(impersonation) = of_session(read, Some(session)).await? else {
453        return Ok(Checked::Absent);
454    };
455    let member_holds =
456        membership::roles::granted(read, &impersonation.area, impersonation.account).await?;
457    let impersonator_holds = membership::roles::granted(
458        read,
459        &impersonation.impersonator_area,
460        impersonation.impersonator,
461    )
462    .await?;
463    if impersonation.still_covers(&member_holds, &impersonator_holds) {
464        return Ok(Checked::By(impersonation.impersonator.uuid()));
465    }
466    Ok(Checked::Beyond(Box::new(impersonation)))
467}
468
469/// For a member area's screens: who is impersonating its member, if anyone, so a page can
470/// show it and offer `/impersonation/stop`.
471pub struct Impersonating(Option<String>);
472
473impl Impersonating {
474    #[must_use]
475    pub fn by(&self) -> Option<&str> {
476        self.0.as_deref()
477    }
478}
479
480impl<A: Area, M> Resolve<RequestContext<A, M>> for Impersonating {
481    async fn resolve(context: &mut RequestContext<A, M>) -> Result<Self> {
482        let current = CurrentSession::resolve(context).await?;
483        let read = Read::resolve(context).await?;
484        let impersonation = of_session(&read, current.id()).await?;
485        Ok(Self(impersonation.map(|impersonation| {
486            impersonation.impersonator_email.as_str().to_owned()
487        })))
488    }
489}