tombstone gate all user paths; cleanup deletes late follower rows; spa self-delete to /; adr 0016 + purge comment

This commit is contained in:
2026-10-26 03:22:13 +01:00
parent caab63596a
commit b9cdb54340
7 changed files with 63 additions and 10 deletions

View File

@@ -27,10 +27,12 @@ pub async fn tombstone_gate(
next.run(request).await next.run(request).await
} }
/// The user id in `/users/{id}/outbox|featured|inbox`. /// The user id in any `/users/{id}/...` path (outbox, inbox, collections,
/// content objects such as `/users/{id}/reviews/{rid}`). The bare actor path
/// has its own tombstone response.
pub fn gated_user(path: &str) -> Option<uuid::Uuid> { pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
let rest = path.strip_prefix("/users/")?; let rest = path.strip_prefix("/users/")?;
let (id, endpoint) = rest.split_once('/')?; let (id, endpoint) = rest.split_once('/')?;
matches!(endpoint, "outbox" | "featured" | "inbox").then_some(())?; (!endpoint.is_empty()).then_some(())?;
uuid::Uuid::parse_str(id).ok() uuid::Uuid::parse_str(id).ok()
} }

View File

@@ -46,14 +46,22 @@ async fn an_unknown_user_is_none() {
} }
#[test] #[test]
fn only_the_per_user_endpoints_are_gated() { fn every_per_user_path_is_gated() {
use activitypub::tombstone_gate::gated_user; use activitypub::tombstone_gate::gated_user;
let id = uuid::Uuid::new_v4(); let id = uuid::Uuid::new_v4();
for endpoint in ["outbox", "featured", "inbox"] { let rid = uuid::Uuid::new_v4();
for endpoint in [
"outbox",
"featured",
"inbox",
"followers",
"following",
&format!("reviews/{rid}"),
] {
assert_eq!(gated_user(&format!("/users/{id}/{endpoint}")), Some(id)); assert_eq!(gated_user(&format!("/users/{id}/{endpoint}")), Some(id));
} }
assert_eq!(gated_user(&format!("/users/{id}")), None); assert_eq!(gated_user(&format!("/users/{id}")), None);
assert_eq!(gated_user(&format!("/users/{id}/followers")), None); assert_eq!(gated_user(&format!("/users/{id}/")), None);
assert_eq!(gated_user("/inbox"), None); assert_eq!(gated_user("/inbox"), None);
assert_eq!(gated_user("/users/not-a-uuid/outbox"), None); assert_eq!(gated_user("/users/not-a-uuid/outbox"), None);
} }

View File

@@ -223,6 +223,16 @@ impl AccountPurger for SqliteAccountPurger {
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(map_sqlx_error)?; .map_err(map_sqlx_error)?;
// A Follow arriving on the shared inbox after the purge can leave
// a late row for the tombstone; delete it explicitly rather than
// rely on the foreign-key cascade of the final `users` delete.
for table in ["ap_followers", "ap_following"] {
sqlx::query(&format!("DELETE FROM {table} WHERE local_user_id = ?"))
.bind(id)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
}
for table in RETAINED_USER_TABLES { for table in RETAINED_USER_TABLES {
sqlx::query(&format!("DELETE FROM {table} WHERE user_id = ?")) sqlx::query(&format!("DELETE FROM {table} WHERE user_id = ?"))
.bind(id) .bind(id)

View File

@@ -696,6 +696,14 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1); assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
assert_eq!(unfinished().await, before); assert_eq!(unfinished().await, before);
// A Follow that reached the shared inbox after the purge left a row.
exec(
&pool,
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
VALUES (?, 'https://late.example/users/zed', 'pending', '2026-01-01T00:00:00Z')",
&[&alice],
)
.await;
// Once they finish, the cleanup takes the tombstone and the history. // Once they finish, the cleanup takes the tombstone and the history.
exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await; exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await;
let removed = p let removed = p
@@ -712,6 +720,11 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
left.iter().all(|p| !p.contains(&alice)), left.iter().all(|p| !p.contains(&alice)),
"no history of the user remains: {left:?}" "no history of the user remains: {left:?}"
); );
assert_eq!(
count_for(&pool, "ap_followers", "local_user_id", &s.user).await,
0,
"late shared-inbox follower row"
);
assert_eq!( assert_eq!(
count_for(&pool, "ap_local_actors", "user_id", &s.user).await, count_for(&pool, "ap_local_actors", "user_id", &s.user).await,
0 0

View File

@@ -20,6 +20,10 @@ use domain::{
/// ///
/// Idempotent: every step is a no-op on a re-run, and the notification is /// Idempotent: every step is a no-op on a re-run, and the notification is
/// skipped once the account is purged. /// skipped once the account is purged.
///
/// A purge that fails after the notification was queued is redelivered and
/// queues the `Delete` again. That is harmless: a repeated `Delete` of an
/// already deleted actor is idempotent on remote servers.
pub struct AccountPurgeHandler { pub struct AccountPurgeHandler {
user: Arc<dyn UserRepository>, user: Arc<dyn UserRepository>,
notifier: Arc<dyn FederationDeletionNotifier>, notifier: Arc<dyn FederationDeletionNotifier>,

View File

@@ -29,11 +29,20 @@ exists), so two admins deleting each other concurrently cannot both succeed; the
`count_active_admins` check is only the early, friendly refusal (409). `count_active_admins` check is only the early, friendly refusal (409).
**Immediate effects, before the purge runs.** Login is refused and tokens are rejected **Immediate effects, before the purge runs.** Login is refused and tokens are rejected
(`ensure_active` in the extractors; the user lookups filter `deleted_at IS NULL`). The profile (`ensure_active` in the extractors; the user lookups filter `deleted_at IS NULL`). The user's
webhook tokens stop ingesting at once (`find_by_token_hash` ignores tokens of tombstoned users,
so the endpoint answers 401). The profile
and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer and `?embed=true` pages 404. The ActivityPub actor document, inbox, outbox and featured answer
410 (`tombstone_gate` for the collection endpoints, `actor_is_gone` for the actor). WebFinger 410 (`tombstone_gate` for every `/users/{id}/...` path, content objects included, and
`actor_is_gone` for the actor). WebFinger
answers 404 (owned by k-ap). answers 404 (owned by k-ap).
**The service actor is not deletable.** The instance's service actor (`UserId::service_actor()`,
seeded by migration 0022, used for signed fetches) is refused by `request_account_deletion`
(403 `Forbidden`), and both UIs hide the action (`is_service_account` on the profile response;
the classic template checks the same). Deleting it would retire its name and, after the
retention period, its keypair, breaking signed fetches for good.
**Purge.** Order matters and every step is idempotent: **Purge.** Order matters and every step is idempotent:
1. Notify the fediverse through `FederationDeletionNotifier`, implemented over k-ap's 1. Notify the fediverse through `FederationDeletionNotifier`, implemented over k-ap's
@@ -47,6 +56,11 @@ answers 404 (owned by k-ap).
`watch_events`, `wrap_up_records`, `import_*`, `blocked_actors`). `PURGED_USER_TABLES`, `watch_events`, `wrap_up_records`, `import_*`, `blocked_actors`). `PURGED_USER_TABLES`,
`URL_KEYED_USER_COLUMNS` and `PAYLOAD_KEYED_USER_COLUMNS` are checked against the schema by a `URL_KEYED_USER_COLUMNS` and `PAYLOAD_KEYED_USER_COLUMNS` are checked against the schema by a
guard test, so a new user-owned table cannot be forgotten. guard test, so a new user-owned table cannot be forgotten.
Global wrap-up reports (`wrap_up_records` with no user) embed `most_active_user`; the purge
sets it to null in any report naming the user and keeps the rest (`wrap_up_records.report_json`
is in `PAYLOAD_KEYED_USER_COLUMNS`).
A failed purge is redelivered and queues the `Delete` again; a repeated `Delete` is
idempotent on remote servers.
3. Image objects (avatar, banner) are deleted after the commit, then the references are dropped 3. Image objects (avatar, banner) are deleted after the commit, then the references are dropped
(`forget_images`), so a crash in between is repaired by redelivery. (`forget_images`), so a crash in between is repaired by redelivery.
@@ -72,7 +86,9 @@ registration path refuses a retired username, so a deleted name cannot be impers
- Events already relayed to NATS JetStream are not erased; they live as long as the stream's - Events already relayed to NATS JetStream are not erased; they live as long as the stream's
retention allows. retention allows.
- A Follow arriving on the shared inbox can create a `Pending` `ap_followers` row for the - A Follow arriving on the shared inbox can create a `Pending` `ap_followers` row for the
tombstoned user; it is removed by the cleanup. tombstoned user; `purge_expired_tombstones` deletes the user's `ap_followers` and
`ap_following` rows explicitly before the `users` row (a test covers it), not by relying on
the foreign-key cascade.
- Remote servers that stay down through the delivery retries, or that ignore `Delete`, may keep - Remote servers that stay down through the delivery retries, or that ignore `Delete`, may keep
stale copies. stale copies.
- A handler that defers a tombstone-naming event forever delays the tombstone (and keypair) - A handler that defers a tombstone-naming event forever delays the tombstone (and keypair)

View File

@@ -133,7 +133,6 @@ function SettingsPage() {
function DangerZone() { function DangerZone() {
const { t } = useTranslation() const { t } = useTranslation()
const { logout } = useAuth() const { logout } = useAuth()
const navigate = useNavigate()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const del = useDeleteOwnAccount() const del = useDeleteOwnAccount()
@@ -148,7 +147,8 @@ function DangerZone() {
{ {
onSuccess: () => { onSuccess: () => {
logout() logout()
navigate({ to: "/login" }) // Full page load: the classic public home, not the SPA login.
window.location.assign("/")
}, },
}, },
) )