fix(security): prevent removing the last active admin

Admins could remove the admin group from, deactivate, or delete the last
active admin, locking the system out of all administration. Add a
count_active_admins() repo method and a _is_last_active_admin() guard, and
block all three operations when they would leave zero active admins.

Refs: porchlight-yq7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-05 13:31:39 +02:00
parent e54764cda9
commit aedb451128
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
5 changed files with 110 additions and 0 deletions

View file

@ -11,6 +11,15 @@ from porchlight.validation import GroupListInput, ProfileUpdate, UsernameInput,
router = APIRouter(prefix="/admin", tags=["admin"])
ADMIN_GROUP = "admin"
async def _is_last_active_admin(request: Request, user: User) -> bool:
"""True if removing this user's admin access would leave zero active admins."""
if not user.active or ADMIN_GROUP not in user.groups:
return False
return await request.app.state.user_repo.count_active_admins() <= 1
async def _get_admin_user(request: Request) -> User | None:
"""Return the current user if they are an admin, else None."""
@ -198,6 +207,9 @@ async def update_user_groups(
if user is None:
return HTMLResponse("User not found", status_code=404)
if ADMIN_GROUP not in validated.group_list and await _is_last_active_admin(request, user):
return HTMLResponse('<div role="alert">Cannot remove the last active admin</div>')
updated = user.model_copy(update={"groups": validated.group_list})
await user_repo.update(updated)
return HTMLResponse('<div role="status">Groups updated</div>')
@ -241,6 +253,9 @@ async def deactivate_user(request: Request, userid: str) -> Response:
if user is None:
return HTMLResponse("User not found", status_code=404)
if await _is_last_active_admin(request, user):
return HTMLResponse('<div role="alert">Cannot deactivate the last active admin</div>')
updated = user.model_copy(update={"active": False})
await user_repo.update(updated)
return HTMLResponse(
@ -350,6 +365,10 @@ async def delete_user(request: Request, userid: str) -> Response:
return HTMLResponse('<div role="alert">Cannot delete your own account</div>')
user_repo = request.app.state.user_repo
target = await user_repo.get_by_userid(userid)
if target is not None and await _is_last_active_admin(request, target):
return HTMLResponse('<div role="alert">Cannot delete the last active admin</div>')
deleted = await user_repo.delete(userid)
if not deleted:
return HTMLResponse("User not found", status_code=404)

View file

@ -25,6 +25,8 @@ class UserRepository(Protocol):
async def count_users(self, query: str | None = None) -> int: ...
async def count_active_admins(self) -> int: ...
async def delete(self, userid: str) -> bool: ...

View file

@ -163,6 +163,17 @@ class SQLiteUserRepository:
row = await cursor.fetchone()
return row[0] if row else 0
async def count_active_admins(self) -> int:
async with self._db.execute(
"""
SELECT COUNT(*) FROM users u
JOIN user_groups g ON g.userid = u.userid
WHERE u.active = 1 AND g.group_name = 'admin'
"""
) as cursor:
row = await cursor.fetchone()
return row[0] if row else 0
async def delete(self, userid: str) -> bool:
cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,))
await self._db.commit()