Skip to content

Clear the public profile when an account is soft-deleted #427

Description

@HMarzban

Summary

The privacy page tells people to email us to delete their account. The only delete tool is the admin ghost-account delete. It soft-deletes anyone who has blocking messages or owns documents. A soft delete sets users.deleted_at and bans the auth user, but the name, photo, bio and links stay readable by anyone. The users read policies are USING (true).

  • Severity: Medium. It breaks a privacy promise. Confidence is medium, because the operator may handle requests by hand.
  • Area: hocuspocus admin API, Supabase Storage
  • Source: security review of 2026-10-06, finding M14

Production check (2026-10-06, read-only)

0 soft-deleted users hold a name, avatar or profile_data. So nothing leaks today. The gap opens with the next soft delete.

Where

  • Promise: apps/webapp/src/pages/privacy.tsx:104-107.
  • Routes: DELETE /api/admin/audit/ghost-accounts/:id and POST /api/admin/audit/ghost-accounts/bulk-delete (apps/hocuspocus.server/src/api/routers/admin.router.ts:222-228).
  • Both soft-delete paths live in apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts. Each sets only deleted_at and ban_duration:
    • deleteGhostAccount, :290-307.
    • bulkDeleteGhostAccounts, :366-382.
  • Profile columns: packages/supabase/scripts/02-users.sql:9-35. display_name is generated from full_name and username, so it follows them. username must match ^[a-z][a-z0-9_-]{2,29}$ and be unique.
  • Read policies users_select and users_public_anon_select: packages/supabase/scripts/13-RLS.sql:60-62 and :322-324. They do not hide deleted_at rows. The column grants hide email already (:71-74).
  • Avatar object path: {userId}/avatar.png in user_avatars (apps/webapp/src/components/settings/hooks/useAvatarUpload.ts:69).

Fix plan

Before you start: #412 makes the ghost delete refuse any account that is not a ghost. A real user who asks for deletion is not a ghost, so that path will refuse them. The maintainer must choose the deletion path for such a request first. Then call the helper below from that path too.

Change only the service layer. Keep the row and its id, so message history still resolves its author.

  1. In adminGhostAccounts.service.ts, add one private helper, softDeleteUsers(client, userIds). Both soft-delete paths call it in place of their own update({ deleted_at }). For each id it:
    • Updates users in one call: deleted_at = now, full_name = null, avatar_url = null, avatar_updated_at = null, profile_data = {}, username = 'deleted_' + <first 12 hex chars of the id, no hyphens>. The placeholder passes the format check, and the id prefix keeps it unique.
    • Lists the {userId}/ folder in user_avatars and removes every object in it through client.storage. A failed removal is logged, not fatal, as the ban failure is today.
    • Keeps the existing ban call.
  2. Keep each path's own result shape and messages. deleteGhostAccount still returns { status: 'soft_delete', reason }.
  3. Do not change the hard-delete path, the controller, or any RLS policy.

Out of scope

Acceptance criteria

  • After a single or bulk soft delete, the user's row has a deleted_ username, and null full_name, avatar_url and avatar_updated_at, and profile_data = {}.
  • GET /rest/v1/users?id=eq.<uid> with the anon key returns no name, photo, bio or links.
  • storage.objects has no user_avatars row under <uid>/.
  • Old chat messages from that user still render, with the placeholder name and the default avatar.
  • A hard delete behaves as before.

Verify

  1. cd apps/hocuspocus.server && bun run typecheck.
  2. In the local stack, sign in as a test user, upload an avatar, fill the profile, and post one chat message.
  3. Soft-delete the user from the admin dashboard ghost-accounts page, or call the DELETE route with an admin token. After Re-check ghost status before an admin delete, and lock the last-admin guard #412 lands, this path refuses a user who signed in. Use the deletion path the maintainer chose instead.
  4. Check in psql:
    • select username, full_name, display_name, avatar_url, avatar_updated_at, profile_data, deleted_at from public.users where id = '<uid>';
    • select name from storage.objects where bucket_id = 'user_avatars' and name like '<uid>/%'; returns no rows.
  5. Open the channel and check the old message.

Related


Generated by Claude Code

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-][Privacy] A soft-deleted account keeps its public profile[/-] [+]Clear the public profile when an account is soft-deleted[/+] on Oct 6, 2026
  3. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Fixed and live.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ProfileSecuritySecurity, access control, and data exposurebugSomething isn't working

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions