feat: account purge handler, tombstone cleanup, AP tombstone signing + 410
This commit is contained in:
@@ -3,6 +3,7 @@ mod import_cleanup;
|
||||
mod movie_dedup;
|
||||
mod processed_events_cleanup;
|
||||
mod refresh_session_cleanup;
|
||||
mod tombstone_cleanup;
|
||||
mod watch_event_cleanup;
|
||||
mod wrapup;
|
||||
|
||||
@@ -11,5 +12,6 @@ pub use import_cleanup::ImportSessionCleanupJob;
|
||||
pub use movie_dedup::MovieDeduplicationJob;
|
||||
pub use processed_events_cleanup::{PROCESSED_EVENTS_RETENTION_DAYS, ProcessedEventsCleanupJob};
|
||||
pub use refresh_session_cleanup::RefreshSessionCleanupJob;
|
||||
pub use tombstone_cleanup::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob};
|
||||
pub use watch_event_cleanup::WatchEventCleanupJob;
|
||||
pub use wrapup::{WrapUpAutoGenerateJob, WrapUpCleanupJob};
|
||||
|
||||
40
crates/application/src/jobs/tombstone_cleanup.rs
Normal file
40
crates/application/src/jobs/tombstone_cleanup.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AccountPurger, PeriodicJob},
|
||||
};
|
||||
|
||||
/// How long a purged account's stripped user row and signing keypair stay.
|
||||
/// Federation deliveries of the account's `Delete` are signed from them, and
|
||||
/// their retries are long over by then.
|
||||
pub const TOMBSTONE_RETENTION_DAYS: i64 = 7;
|
||||
|
||||
pub struct TombstoneCleanupJob {
|
||||
purger: Arc<dyn AccountPurger>,
|
||||
}
|
||||
|
||||
impl TombstoneCleanupJob {
|
||||
pub fn new(purger: Arc<dyn AccountPurger>) -> Self {
|
||||
Self { purger }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for TombstoneCleanupJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(24 * 3600)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let cutoff =
|
||||
chrono::Utc::now().naive_utc() - chrono::Duration::days(TOMBSTONE_RETENTION_DAYS);
|
||||
let n = self.purger.purge_expired_tombstones(cutoff).await?;
|
||||
if n > 0 {
|
||||
tracing::info!("tombstone cleanup: removed {n} purged accounts");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod get_page_viewer;
|
||||
pub mod get_profile_settings;
|
||||
pub mod get_settings;
|
||||
pub mod get_users;
|
||||
pub mod purge_account;
|
||||
pub mod queries;
|
||||
pub mod request_account_deletion;
|
||||
pub mod resolve_username_to_id;
|
||||
|
||||
77
crates/application/src/users/purge_account.rs
Normal file
77
crates/application/src/users/purge_account.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{
|
||||
AccountPurger, EventHandler, FederationDeletionNotifier, ObjectStorage, UserRepository,
|
||||
},
|
||||
};
|
||||
|
||||
/// Purges a deleted account once its `UserDeleted` event arrives.
|
||||
///
|
||||
/// Order matters. Federation is told first (deliveries are only enqueued, and
|
||||
/// signed later from the tombstone the purge leaves behind). A notifier
|
||||
/// failure never blocks the purge: unreachable servers must not keep a
|
||||
/// person's data. Then the database rows go in one transaction, and the
|
||||
/// image objects after the commit. Image references are dropped only once the
|
||||
/// objects are gone, so a crash in between is recovered by the redelivery.
|
||||
///
|
||||
/// Idempotent: every step is a no-op on a re-run, and the notification is
|
||||
/// skipped once the account is purged.
|
||||
pub struct AccountPurgeHandler {
|
||||
user: Arc<dyn UserRepository>,
|
||||
notifier: Arc<dyn FederationDeletionNotifier>,
|
||||
purger: Arc<dyn AccountPurger>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
}
|
||||
|
||||
impl AccountPurgeHandler {
|
||||
pub fn new(
|
||||
user: Arc<dyn UserRepository>,
|
||||
notifier: Arc<dyn FederationDeletionNotifier>,
|
||||
purger: Arc<dyn AccountPurger>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
user,
|
||||
notifier,
|
||||
purger,
|
||||
object_storage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for AccountPurgeHandler {
|
||||
fn name(&self) -> &'static str {
|
||||
"account_purge"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let DomainEvent::UserDeleted { user_id } = event else {
|
||||
return Ok(());
|
||||
};
|
||||
// Gone already (removed by the tombstone cleanup): nothing left to do.
|
||||
if self.user.find_tombstoned_by_id(user_id).await?.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.purger.is_purged(user_id).await?
|
||||
&& let Err(e) = self.notifier.notify_deleted(user_id).await
|
||||
{
|
||||
tracing::warn!(user = %user_id.value(), "federation delete notice failed, purging anyway: {e}");
|
||||
}
|
||||
|
||||
let keys = self.purger.owned_image_keys(user_id).await?;
|
||||
self.purger.purge(user_id).await?;
|
||||
for key in &keys {
|
||||
self.object_storage.delete(key).await?;
|
||||
}
|
||||
if !keys.is_empty() {
|
||||
self.purger.forget_images(user_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
#[path = "jobs/processed_events_cleanup_test.rs"]
|
||||
mod processed_events_cleanup_test;
|
||||
#[path = "jobs/tombstone_cleanup_test.rs"]
|
||||
mod tombstone_cleanup_test;
|
||||
|
||||
59
crates/application/tests/jobs/tombstone_cleanup_test.rs
Normal file
59
crates/application/tests/jobs/tombstone_cleanup_test.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::jobs::{TOMBSTONE_RETENTION_DAYS, TombstoneCleanupJob};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AccountPurger, PeriodicJob},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Purger {
|
||||
cutoffs: Mutex<Vec<chrono::NaiveDateTime>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountPurger for Purger {
|
||||
async fn is_purged(&self, _: &UserId) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn owned_image_keys(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn purge(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn purge_expired_tombstones(
|
||||
&self,
|
||||
cutoff: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
self.cutoffs.lock().unwrap().push(cutoff);
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_tombstones_purged_before_the_retention_window() {
|
||||
assert_eq!(TOMBSTONE_RETENTION_DAYS, 7);
|
||||
let purger = Arc::new(Purger::default());
|
||||
|
||||
TombstoneCleanupJob::new(purger.clone())
|
||||
.run()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cutoffs = purger.cutoffs.lock().unwrap();
|
||||
let expected = chrono::Utc::now().naive_utc() - chrono::Duration::days(7);
|
||||
assert_eq!(cutoffs.len(), 1);
|
||||
assert!((cutoffs[0] - expected).num_seconds().abs() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_daily() {
|
||||
let job = TombstoneCleanupJob::new(Arc::new(Purger::default()));
|
||||
assert_eq!(job.interval(), std::time::Duration::from_secs(86_400));
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod get_profile_settings_test;
|
||||
mod get_settings_test;
|
||||
#[path = "users/get_users_test.rs"]
|
||||
mod get_users_test;
|
||||
#[path = "users/purge_account_test.rs"]
|
||||
mod purge_account_test;
|
||||
#[path = "users/request_account_deletion_test.rs"]
|
||||
mod request_account_deletion_test;
|
||||
#[path = "users/resolve_username_to_id_test.rs"]
|
||||
|
||||
253
crates/application/tests/users/purge_account_test.rs
Normal file
253
crates/application/tests/users/purge_account_test.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::users::purge_account::AccountPurgeHandler;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope, EventId},
|
||||
ports::{
|
||||
AccountPurger, EventConsumer, EventHandler, FederationDeletionNotifier, ObjectStorage,
|
||||
UserRepository,
|
||||
},
|
||||
testing::{InMemoryStore, NoopAckHandle, test_user},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use futures::stream::{self, BoxStream};
|
||||
|
||||
type Log = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
struct Notifier {
|
||||
log: Log,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FederationDeletionNotifier for Notifier {
|
||||
async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push("notify".into());
|
||||
if self.fail {
|
||||
Err(DomainError::InfrastructureError("peer unreachable".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Purger {
|
||||
log: Log,
|
||||
purged: Mutex<bool>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountPurger for Purger {
|
||||
async fn is_purged(&self, _: &UserId) -> Result<bool, DomainError> {
|
||||
Ok(*self.purged.lock().unwrap())
|
||||
}
|
||||
async fn owned_image_keys(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.keys.clone())
|
||||
}
|
||||
async fn purge(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push("purge".into());
|
||||
*self.purged.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push("forget".into());
|
||||
Ok(())
|
||||
}
|
||||
async fn purge_expired_tombstones(&self, _: chrono::NaiveDateTime) -> Result<u64, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Storage {
|
||||
log: Log,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObjectStorage for Storage {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_stream(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push(format!("delete {key}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _: &str) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
handler: Arc<AccountPurgeHandler>,
|
||||
log: Log,
|
||||
store: Arc<InMemoryStore>,
|
||||
user: UserId,
|
||||
}
|
||||
|
||||
async fn fixture(notify_fails: bool, tombstoned: bool) -> Fixture {
|
||||
let store = InMemoryStore::new();
|
||||
let alice = test_user("alice");
|
||||
store.save(&alice).await.unwrap();
|
||||
if tombstoned {
|
||||
store.mark_deleted(alice.id(), &[]).await.unwrap();
|
||||
}
|
||||
let log: Log = Arc::default();
|
||||
let handler = Arc::new(AccountPurgeHandler::new(
|
||||
store.clone(),
|
||||
Arc::new(Notifier {
|
||||
log: log.clone(),
|
||||
fail: notify_fails,
|
||||
}),
|
||||
Arc::new(Purger {
|
||||
log: log.clone(),
|
||||
purged: Mutex::new(false),
|
||||
keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()],
|
||||
}),
|
||||
Arc::new(Storage { log: log.clone() }),
|
||||
));
|
||||
Fixture {
|
||||
handler,
|
||||
log,
|
||||
store,
|
||||
user: alice.id().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn deleted(user: &UserId) -> DomainEvent {
|
||||
DomainEvent::UserDeleted {
|
||||
user_id: user.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entries(log: &Log) -> Vec<String> {
|
||||
log.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handler_name_is_stable() {
|
||||
// The name keys the processed-event ledger; renaming re-runs events.
|
||||
let f = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(fixture(false, true));
|
||||
assert_eq!(f.handler.name(), "account_purge");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifies_first_then_purges_then_removes_images_then_forgets_them() {
|
||||
let f = fixture(false, true).await;
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries(&f.log),
|
||||
[
|
||||
"notify",
|
||||
"purge",
|
||||
"delete avatars/a.webp",
|
||||
"delete banners/a.webp",
|
||||
"forget"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_continues_when_federation_notify_fails() {
|
||||
let f = fixture(true, true).await;
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
let log = entries(&f.log);
|
||||
assert_eq!(log[0], "notify");
|
||||
assert!(log.contains(&"purge".to_string()), "{log:?}");
|
||||
assert!(log.contains(&"forget".to_string()), "{log:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rerun_does_not_notify_again() {
|
||||
let f = fixture(false, true).await;
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
let notices = entries(&f.log).iter().filter(|e| *e == "notify").count();
|
||||
assert_eq!(notices, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_nothing_when_the_account_is_not_tombstoned() {
|
||||
let live = fixture(false, false).await;
|
||||
live.handler.handle(&deleted(&live.user)).await.unwrap();
|
||||
assert!(entries(&live.log).is_empty(), "a live user is never purged");
|
||||
|
||||
let unknown = fixture(false, true).await;
|
||||
unknown
|
||||
.handler
|
||||
.handle(&deleted(&UserId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(entries(&unknown.log).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_other_events() {
|
||||
let f = fixture(false, true).await;
|
||||
f.handler
|
||||
.handle(&DomainEvent::UserUpdated {
|
||||
user_id: f.user.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(entries(&f.log).is_empty());
|
||||
}
|
||||
|
||||
struct TwiceConsumer {
|
||||
id: EventId,
|
||||
event: DomainEvent,
|
||||
}
|
||||
|
||||
impl EventConsumer for TwiceConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let deliveries: Vec<Result<EventEnvelope, DomainError>> = (0..2)
|
||||
.map(|_| {
|
||||
Ok(EventEnvelope::new(
|
||||
Some(self.id),
|
||||
self.event.clone(),
|
||||
Box::new(NoopAckHandle),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
Box::pin(stream::iter(deliveries))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redelivered_event_is_skipped_by_the_ledger() {
|
||||
let f = fixture(false, true).await;
|
||||
let consumer = TwiceConsumer {
|
||||
id: EventId::new(),
|
||||
event: deleted(&f.user),
|
||||
};
|
||||
|
||||
application::worker::WorkerService::new(
|
||||
Arc::new(consumer),
|
||||
vec![f.handler.clone() as Arc<dyn EventHandler>],
|
||||
f.store.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
.run(tokio::sync::watch::channel(false).1)
|
||||
.await;
|
||||
|
||||
let purges = entries(&f.log).iter().filter(|e| *e == "purge").count();
|
||||
assert_eq!(purges, 1);
|
||||
}
|
||||
Reference in New Issue
Block a user