Skip to content

Re-check ghost status before an admin delete, and lock the last-admin guard #412

Closed

Description

@HMarzban

Summary

Two admin-only safety gaps:

  1. Ghost-account delete acts on any user id the admin sends. The single and the bulk path never re-check that the user is still a ghost, and never refuse an admin. A hard delete cascades to admin_users, which bypasses the last-admin guard. If the impact check fails, the single path treats the user as non-blocking and hard-deletes. The bulk path does the same, and also when the impact read throws.
  2. The last-admin guard can race. toggleAdminRole reads the admin count and deletes the row in two separate calls, with no lock. Two admins who remove each other at the same moment can leave zero admins.

Only an admin can trigger either one. This is a safety and data-loss issue, not an outside attack. The delete reads no cache; it trusts the id it is given.

  • Severity: Low
  • Area: apps/hocuspocus.server admin API, admin_users
  • Source: security review of 2026-10-06, finding L14

Where

  • Single delete route, no param schema: apps/hocuspocus.server/src/api/routers/admin.router.ts:222. The unused ghostDeleteSchema (apps/hocuspocus.server/src/schemas/admin.schema.ts:97-99) names the field userId, but the route param is :id.
  • Controllers: deleteGhostAccount and bulkDeleteGhostAccounts, apps/hocuspocus.server/src/api/controllers/admin-audit.controller.ts:351-381.
  • Service, single: apps/hocuspocus.server/src/api/services/adminGhostAccounts.service.ts:276-315. fetchGhostDeletionImpact ignores the RPC error (:249-255), so a failed check reads as "no blocking messages".
  • Service, bulk: same file :324-401. An RPC error reads as null through fetchGhostDeletionImpact. A thrown impact read also becomes impact: null (:336-339). Then null goes to hard delete (:358-360).
  • Ghost rules: classifyGhost, same file :73-92.
  • Hard delete cascades to admin_users: packages/supabase/scripts/02-z-admin-users.sql:11-12.
  • Last-admin race: toggleAdminRole, apps/hocuspocus.server/src/api/services/adminStats.service.ts:393-420 (count at :405-408, delete at :410). The controller already refuses a change to the caller's own role (apps/hocuspocus.server/src/api/controllers/admin.controller.ts:135-137).

Fix plan

  1. Validate the route param. Rename the ghostDeleteSchema field from userId to id, and register zValidator('param', ghostDeleteSchema, houseEnvelopeHook) on admin.delete('/audit/ghost-accounts/:id', …). GhostDeleteInput follows the schema, and nothing imports it.
  2. Add one guard in adminGhostAccounts.service.ts, assertDeletableGhost(client, userId), and call it per id in both deleteGhostAccount and bulkDeleteGhostAccounts. It refuses when:
    • client.auth.admin.getUserById(userId) fails or finds no user;
    • classifyGhost(user, 0) returns null, so the user is no ghost under any age filter;
    • the id has a row in admin_users.
      The caller is always an admin, so the admin refusal also covers a self-delete.
  3. Fail closed on the impact check.
    • fetchGhostDeletionImpact returns { error } when the RPC errors, like getGhostDeletionImpact does.
    • The single path returns { status: 'error' }.
    • The bulk path counts that id as failed and moves on.
    • Never fall through to hard delete.
  4. Make the revoke atomic in SQL. Add public.admin_revoke_admin(p_user_id uuid) returns boolean.
    • It runs lock table public.admin_users in share row exclusive mode, counts the rows, and refuses when the count is 1 or less.
    • Otherwise it deletes the row and returns true. It returns false when no row exists.
    • security invoker. Revoke EXECUTE from public, anon, authenticated and grant it to service_role.
    • toggleAdminRole calls it through supabase.rpc in place of the count and the delete. Map a refusal to { status: 'last_admin' }.
  5. Repo rules for step 4:
    • Add a migration under packages/supabase/migrations/, and mirror the function in packages/supabase/scripts/02-z-admin-users.sql.
    • Regenerate seed.sql with bun run --filter @docs.plus/supabase_back seed.
    • Run bun run --filter @docs.plus/supabase_back types. It adds the new function, so commit the regenerated apps/webapp/src/types/supabase.ts.

Layer: the ghost rules stay in the service, beside classifyGhost. Do not move them into the controller, and do not add a ghost re-check to SQL. Only the count-and-delete moves to SQL, because only Postgres can lock it.

Out of scope

Acceptance criteria

  • Deleting a user who signed in since the list loaded is refused, on the single and the bulk path.
  • Deleting an admin through either ghost path is refused.
  • An impact-check error stops the delete. The single path returns an error, and the bulk path counts that id as failed.
  • A non-uuid :id gets the house 400.
  • Two concurrent revokes of the last two admins leave at least one admin.

Verify

  • Local stack: list ghosts in the admin dashboard, sign in once as one of them, then delete that user from the stale list. Expect a refusal.
  • In two psql sessions on the local database, begin; in both, then select public.admin_revoke_admin('<A>'); and select public.admin_revoke_admin('<B>');, then commit; both. Then select count(*) from public.admin_users; must return 1 or more.
  • bun run --filter @docs.plus/hocuspocus typecheck.

Related


Generated by Claude Code

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-]Admin ghost-account delete acts on any id without re-checks, and the last-admin guard can race[/-] [+]Re-check ghost status before an admin delete, and lock the last-admin guard[/+] on Oct 6, 2026
  3. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Fixed and live.

    • Commits: ce2bfde30, 7d92a7507
    • Deployed in bbdaae66c (production run 37976785981, green).
    • Verified on production: migration 20261009120400 is applied, and admin_revoke_admin(uuid) EXECUTE is false for anon and authenticated, true for service_role. The ghost delete re-reads each user, and an Auth error that is not a 404 now answers 500.
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

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

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions