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, 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            (
60                include_str!("screen.html"),
61                include_bytes!(concat!(env!("OUT_DIR"), "/shipped/account_data/screen.sft")),
62                Some(describe),
63            ),
64            page,
65        )
66        .name("account_data_page");
67    routes
68        .post("/account-data/export", export::<A>)
69        .name("export_account_data");
70    routes
71        .post("/account-data/close", close::<A>)
72        .name("close_account")
73        .then_redirect(A::SIGN_IN);
74}
75
76#[derive(ReadModel)]
77pub struct Page {}
78
79async fn page() -> Result<Page> {
80    Ok(Page {})
81}
82
83async fn export<A: MemberArea>(
84    principal: Principal<A>,
85    _own: OwnSession,
86    read: Read,
87    others: Extensions<dyn PersonalData>,
88) -> Result<Value> {
89    let account = account_of(&principal);
90    let mut exported = Map::new();
91    exported.insert("sofie_identity".to_owned(), own(&read, account).await?);
92    for data in others.iter() {
93        exported.insert(
94            data.name().to_owned(),
95            data.export(&read, account.uuid()).await?,
96        );
97    }
98    Ok(Value::Object(exported))
99}
100
101async fn own(read: &Read, account: AccountId) -> Result<Value> {
102    let held = Account::stored(read, account).await?;
103    let memberships = Membership::query()
104        .where_(Membership::ACCOUNT.eq(account))
105        .all(read)
106        .await?;
107    #[cfg_attr(
108        not(any(
109            feature = "sessions",
110            feature = "passkeys",
111            feature = "oauth",
112            feature = "totp",
113            feature = "tokens",
114            feature = "impersonation"
115        )),
116        expect(unused_mut)
117    )]
118    let mut own = json!({
119        "account": {
120            "email": held.email().as_str(),
121            "created_at": held.created_at(),
122            "verified_at": held.verified_at(),
123        },
124        "memberships": memberships.iter().map(|membership| json!({
125            "area": membership.area(),
126            "created_at": membership.created_at(),
127        })).collect::<Vec<_>>(),
128    });
129    #[cfg(feature = "sessions")]
130    {
131        own["sessions"] = crate::sessions::exported(read, account).await?;
132    }
133    #[cfg(feature = "passkeys")]
134    {
135        own["passkeys"] = crate::passkeys::exported(read, account).await?;
136    }
137    #[cfg(feature = "oauth")]
138    {
139        own["connections"] = crate::oauth::exported(read, account).await?;
140    }
141    #[cfg(feature = "totp")]
142    {
143        own["second_factor"] = crate::two_factor::exported(read, account).await?;
144    }
145    #[cfg(feature = "tokens")]
146    {
147        own["tokens"] = crate::tokens::exported(read, account).await?;
148    }
149    #[cfg(feature = "impersonation")]
150    {
151        own["impersonations"] = crate::impersonation::exported(read, account).await?;
152    }
153    Ok(own)
154}
155
156#[derive(Input)]
157pub struct Closing {
158    /// Typed again, so no stray click closes an account.
159    email: Email,
160}
161
162struct Closed {
163    account: Account,
164    records: Vec<Box<dyn Closable>>,
165}
166
167impl Outcome for Closed {
168    fn plan(&self, plan: &mut Plan<'_>) -> Result<()> {
169        Outcome::plan(&self.account, plan)?;
170        self.records
171            .iter()
172            .try_for_each(|record| record.plan(plan))?;
173        sofie::db::forget_principal(plan, self.account.id().uuid());
174        Ok(())
175    }
176
177    fn committed(&mut self, stamp: Stamp) {
178        Outcome::committed(&mut self.account, stamp);
179        for record in &mut self.records {
180            record.committed(stamp);
181        }
182    }
183}
184
185/// Unscoped, as the sessions of the impersonations the account started are its members'.
186async fn close<A: MemberArea>(
187    principal: Principal<A>,
188    _own: OwnSession,
189    closing: Closing,
190    read: Read,
191    unscoped: Unscoped,
192    others: Extensions<dyn PersonalData>,
193    session: Session,
194) -> Result<()> {
195    let id = account_of(&principal);
196    let mut account = Account::stored(&read, id).await?;
197    if *account.email() != closing.email {
198        return Err(Error::invalid("email", CONFIRM));
199    }
200    account.close()?;
201    account.raise(AccountClosed { account: id.uuid() })?;
202    let mut records: Vec<Box<dyn Closable>> = Vec::new();
203    #[cfg(feature = "account-links")]
204    records.push(Box::new(
205        crate::account_link::closing(&read, id, unscoped.0.clock().now()).await?,
206    ));
207    #[cfg(feature = "sessions")]
208    records.push(Box::new(
209        crate::sessions::closing(&unscoped.0.read(), id, unscoped.0.clock().now()).await?,
210    ));
211    #[cfg(feature = "passkeys")]
212    records.push(Box::new(crate::passkeys::removing(&read, id).await?));
213    #[cfg(feature = "oauth")]
214    records.push(Box::new(crate::oauth::removing(&read, id).await?));
215    #[cfg(feature = "totp")]
216    records.push(Box::new(crate::two_factor::removing(&read, id).await?));
217    #[cfg(feature = "tokens")]
218    records.push(Box::new(crate::tokens::revoking(&read, id).await?));
219    for data in others.iter() {
220        records.extend(data.closing(&read, id.uuid()).await?);
221    }
222    unscoped.0.commit(&mut Closed { account, records }).await?;
223    session.sign_out();
224    Ok(())
225}
226
227#[cfg(feature = "notify")]
228mod notifications {
229    use sofie::db::{BoxFuture, Db, LocalJobs, Reach, Uuid};
230    use sofie::prelude::*;
231    use sofie::serde_json::Value;
232
233    use super::{Closable, PersonalData};
234
235    /// For [`super::register`] in an app with sofie's `notify`: a closing account's
236    /// notifications, choices and browsers are erased and its digest stops.
237    pub struct Notifications(Db);
238
239    impl Notifications {
240        #[must_use]
241        pub fn new(db: &Db) -> Self {
242            Self(db.clone())
243        }
244    }
245
246    impl PersonalData for Notifications {
247        fn name(&self) -> &'static str {
248            "sofie_notify"
249        }
250
251        fn export<'a>(&'a self, read: &'a Read, account: Uuid) -> BoxFuture<'a, Result<Value>> {
252            Box::pin(sofie::notify::personal_data::exported(read, account))
253        }
254
255        fn closing<'a>(
256            &'a self,
257            _read: &'a Read,
258            account: Uuid,
259        ) -> BoxFuture<'a, Result<Vec<Box<dyn Closable>>>> {
260            Box::pin(async move {
261                let jobs = LocalJobs::within(&self.0, Reach::OwnedBy(account));
262                let forgotten = sofie::notify::personal_data::forgetting(&jobs, account).await?;
263                let closing: Vec<Box<dyn Closable>> = vec![Box::new(forgotten)];
264                Ok(closing)
265            })
266        }
267    }
268}