Description
The DELETE /auth/users/{user_id} route in omnigent/server/routes/accounts_auth.py enforces a "never leave zero admins" invariant with a check-then-act pattern split across two unlocked database transactions in omnigent/server/accounts_store.py:
- Read the current admin list (
account_store.list_users()).
- If another admin exists, delete (
account_store.delete_user(...)).
Nothing ties the read and delete together atomically, so the invariant is not actually enforced the way it is meant to be. If two delete requests target two different admins at the same time, each request's read sees the other as "the one remaining admin," both checks pass, and both deletes commit, leaving zero admins with no in-app recovery path. This succeeds silently (204 on both requests) and is exploitable on any deployment running more than one server process against the same database.
The invariant should make it impossible for concurrent requests to jointly leave zero admins. What actually happens is that both requests' "is there another admin" checks run before either delete commits, so each sees the other admin as still present and passes, both deletes apply, and list_users() ends up returning zero admins.
Steps to reproduce
- Seed a deploy with two admins,
admin1 and admin2.
- Run more than one server process against the same database (e.g. two
omnigent server instances behind a load balancer, or a deploy scaled past one replica). A single process won't reliably reproduce this over HTTP, since the route handler has no await between its read and its delete, so one process can't be preempted mid-check by another request.
- Send
DELETE /auth/users/admin1 and DELETE /auth/users/admin2 to two different processes at the same time, so both requests' list_users() checks run before either delete commits.
- Each request's "is there another admin" check sees the other as still present, so both checks pass and both deletes apply via
delete_user(...).
GET /auth/users now shows zero admins, with no way to promote anyone back through the API.
Version
0.7.0.dev0
OS
macOs 15.5
Description
The
DELETE /auth/users/{user_id}route inomnigent/server/routes/accounts_auth.pyenforces a "never leave zero admins" invariant with a check-then-act pattern split across two unlocked database transactions inomnigent/server/accounts_store.py:account_store.list_users()).account_store.delete_user(...)).Nothing ties the read and delete together atomically, so the invariant is not actually enforced the way it is meant to be. If two delete requests target two different admins at the same time, each request's read sees the other as "the one remaining admin," both checks pass, and both deletes commit, leaving zero admins with no in-app recovery path. This succeeds silently (
204on both requests) and is exploitable on any deployment running more than one server process against the same database.The invariant should make it impossible for concurrent requests to jointly leave zero admins. What actually happens is that both requests' "is there another admin" checks run before either delete commits, so each sees the other admin as still present and passes, both deletes apply, and
list_users()ends up returning zero admins.Steps to reproduce
admin1andadmin2.omnigent serverinstances behind a load balancer, or a deploy scaled past one replica). A single process won't reliably reproduce this over HTTP, since the route handler has noawaitbetween its read and its delete, so one process can't be preempted mid-check by another request.DELETE /auth/users/admin1andDELETE /auth/users/admin2to two different processes at the same time, so both requests'list_users()checks run before either delete commits.delete_user(...).GET /auth/usersnow shows zero admins, with no way to promote anyone back through the API.Version
0.7.0.dev0
OS
macOs 15.5