docs: adr 0016 account deletion
This commit is contained in:
@@ -22,7 +22,7 @@ check; unlike `UNAUTHORIZED` it must not trigger a token refresh), `FORBIDDEN`,
|
||||
**One constructor.** `errors::refuse(status, code, message) -> Response` is the only place a JSON
|
||||
error body is built. `ApiError` maps every `DomainError` variant to it with the status mapping that
|
||||
existed before (`InvalidRating`/`ValidationError` 400, `NotFound` 404, `Unauthorized` 401,
|
||||
`Forbidden` 403, `InfrastructureError` 500 with the generic message `internal server error` and the
|
||||
`Forbidden` 403, `InvalidCredentials` 401 (`INVALID_CREDENTIALS`), `InfrastructureError` 500 with the generic message `internal server error` and the
|
||||
cause logged, never sent).
|
||||
|
||||
**Extractors.** `extractors::Body<T>`, `Params<T>` and `PathId<T>` wrap axum's `Json`, `Query` and
|
||||
|
||||
87
docs/adr/0016-account-deletion.md
Normal file
87
docs/adr/0016-account-deletion.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Account deletion is a tombstone now and a purge later
|
||||
|
||||
Deleting an account has to take effect at once (the person is gone from the instance and the
|
||||
fediverse) yet erase a lot: dozens of tables, images in object storage, follows on other servers,
|
||||
and signed `Delete` deliveries that need the account's key long after the user row would
|
||||
normally be gone. Doing it in one request would hold a transaction over federation and storage
|
||||
calls, and a crash halfway would leave a half-deleted person.
|
||||
|
||||
## Decision
|
||||
|
||||
**Two phases.**
|
||||
|
||||
1. *Request* (`request_account_deletion`). `UserRepository::mark_deleted` runs one transaction
|
||||
that stamps `users.deleted_at` (the tombstone), inserts the username into `retired_usernames`,
|
||||
deletes the user's `refresh_sessions`, and writes a `UserDeleted` event to the outbox
|
||||
(ADR-0015). Migration `0102_account_deletion.sql` adds `users.deleted_at`, `users.purged_at`
|
||||
and `retired_usernames` (`username` primary key, `COLLATE NOCASE`, `retired_at`).
|
||||
2. *Purge* (`AccountPurgeHandler`, handler name `account_purge`, runs in the worker on
|
||||
`UserDeleted`).
|
||||
|
||||
**Authorization.** A user deletes themselves, an admin deletes anyone (others get 403;
|
||||
`DELETE /api/v1/users/{id}` takes `AuthenticatedUser` and the use case enforces the role, so
|
||||
there is one policy point). The proof is always the *actor's* password: the user's own, or the
|
||||
admin's. A wrong password is `DomainError::InvalidCredentials`: 401 with code
|
||||
`INVALID_CREDENTIALS` (ADR-0014), distinct from `UNAUTHORIZED` so the SPA does not mistake it for
|
||||
an expired session and refresh and retry. The last active admin cannot be deleted. The guard
|
||||
is in the `mark_deleted` `UPDATE` itself (the row is stamped only if another non-deleted admin
|
||||
exists), so two admins deleting each other concurrently cannot both succeed; the use case's
|
||||
`count_active_admins` check is only the early, friendly refusal (409).
|
||||
|
||||
**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
|
||||
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
|
||||
answers 404 (owned by k-ap).
|
||||
|
||||
**Purge.** Order matters and every step is idempotent:
|
||||
|
||||
1. Notify the fediverse through `FederationDeletionNotifier`, implemented over k-ap's
|
||||
`broadcast_delete`. That only queues signed deliveries. A notifier failure is logged and never
|
||||
blocks the purge, and the notice is skipped once the account is purged.
|
||||
2. `AccountPurger::purge`: one transaction first strips the `users` row (which also claims the
|
||||
purge: only a tombstoned, unpurged account matches), then deletes `ap_announces` of the
|
||||
user's content and by the user, the *finished* `event_queue` rows whose payload names the
|
||||
user, other accounts' follows of this actor (`ap_followers`/`ap_following` by actor URL), and
|
||||
every row in `PURGED_USER_TABLES` (including tables with no foreign key to `users`, such as
|
||||
`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
|
||||
guard test, so a new user-owned table cannot be forgotten.
|
||||
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.
|
||||
|
||||
**The tombstone.** The stripped `users` row keeps `id`, `username`, `role` and the `ap_local_actors`
|
||||
keypair (`RETAINED_USER_TABLES`), and nothing else: the email becomes the placeholder
|
||||
`deleted-<id>@deleted.invalid` (which frees the real address for a new registration), the
|
||||
password hash becomes `!purged`, display name, bio, `also_known_as` and image paths are cleared,
|
||||
and `purged_at` is set. The row stays so queued `Delete` deliveries can still be signed.
|
||||
Broadcasts for a tombstoned author are suppressed (the handler skips them and succeeds) except
|
||||
the `Delete` itself.
|
||||
|
||||
**Cleanup.** `TombstoneCleanupJob` runs daily and removes tombstones whose `purged_at` is older
|
||||
than `TOMBSTONE_RETENTION_DAYS` (7): all remaining `event_queue` rows naming the user (the
|
||||
`UserDeleted` trigger and the `Delete` deliveries), the keypair, and the `users` row. A tombstone
|
||||
that an unfinished (pending/processing) event still names waits for the next run; unfinished
|
||||
events are never deleted.
|
||||
|
||||
**Usernames are never reused.** `retired_usernames` is permanent and survives cleanup; the
|
||||
registration path refuses a retired username, so a deleted name cannot be impersonated.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- Events already relayed to NATS JetStream are not erased; they live as long as the stream's
|
||||
retention allows.
|
||||
- A Follow arriving on the shared inbox can create a `Pending` `ap_followers` row for the
|
||||
tombstoned user; it is removed by the cleanup.
|
||||
- Remote servers that stay down through the delivery retries, or that ignore `Delete`, may keep
|
||||
stale copies.
|
||||
- A handler that defers a tombstone-naming event forever delays the tombstone (and keypair)
|
||||
removal. No event is lost, and nothing logs this case.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Deletion is visible instantly and has no undo; once the purge has run the data is gone.
|
||||
- Federation and storage failures cannot strand an account half-deleted: the purge is
|
||||
redelivered and each step tolerates a re-run.
|
||||
- The worker owns the destructive work, so the server needs no new infrastructure beyond the
|
||||
outbox it already writes to.
|
||||
Reference in New Issue
Block a user