Skip to main content

sofie_identity/account_data/
mod.rs

1//! Data export and account closing. Closing anonymizes rather than deletes, so records of
2//! other modules that name the account stay whole; [`AccountClosed`] tells those modules
3//! to erase what they hold.
4
5use std::sync::Arc;
6
7use sofie::db::{BoxFuture, Db, Extensions, Outcome, Plan, Reply, Stamp, Uuid};
8use sofie::http::{Routes, Shipped};
9use sofie::prelude::*;
10use sofie::serde_json::{Map, Value, json};
11
12use crate::account::Account;
13pub use crate::account::Closable;
14use crate::membership::Membership;
15use crate::signing_in::OwnSession;
16use crate::{AccountId, MemberArea, Unscoped, account_of};
17
18#[cfg(feature = "notify")]
19pub use notifications::Notifications;
20
21const CONFIRM: &str = "is not this account's address";
22
23/// What a module holds of an account, exported beside sofie-identity's own records.
24pub trait PersonalData: Send + Sync + 'static {
25    fn name(&self) -> &'static str;
26
27    fn export<'a>(&'a self, read: &'a Read, account: Uuid) -> BoxFuture<'a, Result<Value>>;
28
29    /// Committed with the closing; an error refuses it, such as one naming what the account
30    /// still holds alone.
31    fn closing<'a>(
32        &'a self,
33        read: &'a Read,
34        account: Uuid,
35    ) -> BoxFuture<'a, Result<Vec<Box<dyn Closable>>>> {
36        let _ = (read, account);
37        Box::pin(std::future::ready(Ok(Vec::new())))
38    }
39}
40
41pub fn register(db: &Db, data: Arc<dyn PersonalData>) {
42    sofie::db::extend(db, data);
43}
44
45/// Raised when an account closes; a module listens with `#[on]` to erase or anonymize its rows.
46#[derive(Event)]
47pub struct AccountClosed {
48    pub account: Uuid,
49}
50
51/// Each member exports and closes only its own account.
52pub fn routes<A: MemberArea>(routes: &mut Routes<A>) {
53    let describe: sofie::http::Describe =
54        |screens, key| screens.describe_form::<Closing>(key, "close-account");
55    routes
56        .shipped(
57            "/account-data",
58            "account_data",
59            (include_str!("screen.html"), Some(describe)),
60            page,
61        )
62        .name("account_data_page");
63    routes
64        .post("/account-data/export", export::<A>)
65        .name("export_account_data");
66    routes
67        .post("/account-data/close", close::<A>)
68        .name("close_account")
69        .then_redirect(A::SIGN_IN);
70}
71
72#[derive(ReadModel)]
73pub struct Page {}
74
75async fn page() -> Result<Page> {
76    Ok(Page {})
77}
78
79pub struct Exported(Value);
80
81impl Outcome for Exported {
82    fn reply(self) -> Result<Reply> {
83        Ok(Reply::Json(self.0))
84    }
85}
86
87async fn export<A: MemberArea>(
88    principal: Principal<A>,
89    _own: OwnSession,
90    read: Read,
91    others: Extensions<dyn PersonalData>,
92) -> Result<Exported> {
93    let account = account_of(&principal);
94    let mut exported = Map::new();
95    exported.insert("sofie_identity".to_owned(), own(&read, account).await?);
96    for data in others.iter() {
97        exported.insert(
98            data.name().to_owned(),
99            data.export(&read, account.uuid()).await?,
100        );
101    }
102    Ok(Exported(Value::Object(exported)))
103}
104
105async fn own(read: &Read, account: AccountId) -> Result<Value> {
106    let held = Account::stored(read, account).await?;
107    let memberships = Membership::query()
108        .where_(Membership::ACCOUNT.eq(account))
109        .all(read)
110        .await?;
111    #[cfg_attr(
112        not(any(
113            feature = "sessions",
114            feature = "passkeys",
115            feature = "oauth",
116            feature = "totp",
117            feature = "tokens",
118            feature = "impersonation"
119        )),
120        expect(unused_mut)
121    )]
122    let mut own = json!({
123        "account": {
124            "email": held.email().as_str(),
125            "created_at": held.created_at(),
126            "verified_at": held.verified_at(),
127        },
128        "memberships": memberships.iter().map(|membership| json!({
129            "area": membership.area(),
130            "created_at": membership.created_at(),
131        })).collect::<Vec<_>>(),
132    });
133    #[cfg(feature = "sessions")]
134    {
135        own["sessions"] = crate::sessions::exported(read, account).await?;
136    }
137    #[cfg(feature = "passkeys")]
138    {
139        own["passkeys"] = crate::passkeys::exported(read, account).await?;
140    }
141    #[cfg(feature = "oauth")]
142    {
143        own["connections"] = crate::oauth::exported(read, account).await?;
144    }
145    #[cfg(feature = "totp")]
146    {
147        own["second_factor"] = crate::two_factor::exported(read, account).await?;
148    }
149    #[cfg(feature = "tokens")]
150    {
151        own["tokens"] = crate::tokens::exported(read, account).await?;
152    }
153    #[cfg(feature = "impersonation")]
154    {
155        own["impersonations"] = crate::impersonation::exported(read, account).await?;
156    }
157    Ok(own)
158}
159
160#[derive(Input)]
161pub struct Closing {
162    /// Typed again, so no stray click closes an account.
163    email: Email,
164}
165
166struct Closed {
167    account: Account,
168    records: Vec<Box<dyn Closable>>,
169}
170
171impl Outcome for Closed {
172    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
173        Outcome::plan(&self.account, plan)?;
174        self.records
175            .iter()
176            .try_for_each(|record| record.plan(plan))?;
177        sofie::db::forget_principal(plan, self.account.id().uuid());
178        Ok(())
179    }
180
181    fn committed(&mut self, stamp: Stamp) {
182        Outcome::committed(&mut self.account, stamp);
183        for record in &mut self.records {
184            record.committed(stamp);
185        }
186    }
187}
188
189/// Unscoped, as the sessions of the impersonations the account started are its members'.
190async fn close<A: MemberArea>(
191    principal: Principal<A>,
192    _own: OwnSession,
193    closing: Closing,
194    read: Read,
195    unscoped: Unscoped,
196    others: Extensions<dyn PersonalData>,
197    session: Session,
198) -> Result<()> {
199    let id = account_of(&principal);
200    let mut account = Account::stored(&read, id).await?;
201    if *account.email() != closing.email {
202        return Err(Error::invalid("email", CONFIRM));
203    }
204    account.close()?;
205    account.raise(AccountClosed { account: id.uuid() })?;
206    let mut records: Vec<Box<dyn Closable>> = Vec::new();
207    #[cfg(feature = "account-links")]
208    records.push(Box::new(
209        crate::account_link::closing(&read, id, unscoped.0.clock().now()).await?,
210    ));
211    #[cfg(feature = "sessions")]
212    records.push(Box::new(
213        crate::sessions::closing(&unscoped.0.read(), id, unscoped.0.clock().now()).await?,
214    ));
215    #[cfg(feature = "passkeys")]
216    records.push(Box::new(crate::passkeys::removing(&read, id).await?));
217    #[cfg(feature = "oauth")]
218    records.push(Box::new(crate::oauth::removing(&read, id).await?));
219    #[cfg(feature = "totp")]
220    records.push(Box::new(crate::two_factor::removing(&read, id).await?));
221    #[cfg(feature = "tokens")]
222    records.push(Box::new(crate::tokens::revoking(&read, id).await?));
223    for data in others.iter() {
224        records.extend(data.closing(&read, id.uuid()).await?);
225    }
226    unscoped.0.commit(&mut Closed { account, records }).await?;
227    session.sign_out();
228    Ok(())
229}
230
231#[cfg(feature = "notify")]
232mod notifications {
233    use sofie::db::{BoxFuture, Db, LocalJobs, Reach, Uuid};
234    use sofie::prelude::*;
235    use sofie::serde_json::Value;
236
237    use super::{Closable, PersonalData};
238
239    /// For [`super::register`] in an app with sofie's `notify`: a closing account's
240    /// notifications, choices and browsers are erased and its digest stops.
241    pub struct Notifications(Db);
242
243    impl Notifications {
244        #[must_use]
245        pub fn new(db: &Db) -> Self {
246            Self(db.clone())
247        }
248    }
249
250    impl PersonalData for Notifications {
251        fn name(&self) -> &'static str {
252            "sofie_notify"
253        }
254
255        fn export<'a>(&'a self, read: &'a Read, account: Uuid) -> BoxFuture<'a, Result<Value>> {
256            Box::pin(sofie::notify::personal_data::exported(read, account))
257        }
258
259        fn closing<'a>(
260            &'a self,
261            _read: &'a Read,
262            account: Uuid,
263        ) -> BoxFuture<'a, Result<Vec<Box<dyn Closable>>>> {
264            Box::pin(async move {
265                let jobs = LocalJobs::within(&self.0, Reach::OwnedBy(account));
266                let forgotten = sofie::notify::personal_data::forgetting(&jobs, account).await?;
267                let closing: Vec<Box<dyn Closable>> = vec![Box::new(forgotten)];
268                Ok(closing)
269            })
270        }
271    }
272}