Skip to content

Commit b8ed4a1

Browse files
authored
Merge pull request #792 from seapagan/feature/admin-deletion-protection
Prevent last admin from deleting themselves
2 parents 40b9792 + 6e04b3f commit b8ed4a1

File tree

11 files changed

+373
-77
lines changed

11 files changed

+373
-77
lines changed

app/commands/user.py

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ async def _create_user(user_data: dict[str, str | RoleType]) -> None:
139139
user_level = "Admin" if admin else ""
140140
rprint(
141141
f"\n[green]-> {user_level} User [bold]{user_data['email']}"
142-
"[/bold] added succesfully.\n"
142+
"[/bold] added successfully.\n"
143143
)
144144
except HTTPException as exc:
145145
rprint(f"\n[red]-> ERROR adding User : [bold]{exc.detail}\n")
@@ -246,7 +246,7 @@ async def _verify_user(user_id: int) -> User | None:
246246
user = aiorun(_verify_user(user_id))
247247
if user:
248248
rprint(
249-
f"\n[green]-> User [bold]{user_id}[/bold] verified succesfully.\n"
249+
f"\n[green]-> User [bold]{user_id}[/bold] verified successfully.\n"
250250
)
251251
else:
252252
rprint("\n[red]-> ERROR verifying User : [bold]User not found\n")
@@ -289,7 +289,7 @@ async def _ban_user(user_id: int, *, unban: bool | None) -> User | None:
289289
if user:
290290
rprint(
291291
f"\n[green]-> User [bold]{user_id}[/bold] "
292-
f"[red]{'UN' if unban else ''}BANNED[/red] succesfully."
292+
f"[red]{'UN' if unban else ''}BANNED[/red] successfully."
293293
)
294294
show_table("", [user])
295295
else:
@@ -338,7 +338,7 @@ async def _toggle_admin(
338338
status = "removed from" if remove else "granted to"
339339
rprint(
340340
f"\n[green]-> Admin status [bold]{status}[/bold] "
341-
f"User [bold]{user_id}[/bold] succesfully."
341+
f"User [bold]{user_id}[/bold] successfully."
342342
)
343343
show_table("", [user])
344344
else:
@@ -356,31 +356,25 @@ def delete(
356356
) -> None:
357357
"""Delete the user with the given id."""
358358

359-
async def _delete_user(user_id: int) -> User | None:
359+
async def _delete_user(user_id: int) -> None:
360360
"""Async function to delete a user."""
361361
try:
362362
async with async_session() as session:
363363
await check_db_initialized(session)
364-
user = await session.get(User, user_id)
365-
if user:
366-
await session.delete(user)
367-
await session.commit()
364+
await UserManager.delete_user(user_id, session)
365+
await session.commit()
366+
except HTTPException as exc:
367+
rprint(f"\n[RED]-> ERROR deleting that User : [bold]{exc.detail}\n")
368+
raise typer.Exit(1) from exc
368369
except SQLAlchemyError as exc:
369370
rprint(f"\n[RED]-> ERROR deleting that User : [bold]{exc}\n")
370371
raise typer.Exit(1) from exc
371-
else:
372-
return user
373372

374-
user = aiorun(_delete_user(user_id))
375-
376-
if user:
377-
rprint(
378-
f"\n[green]-> User [bold]{user_id}[/bold] "
379-
f"[red]DELETED[/red] succesfully."
380-
)
381-
else:
382-
rprint("\n[red]-> ERROR deleting that User : [bold]User not found\n")
383-
raise typer.Exit(1)
373+
aiorun(_delete_user(user_id))
374+
rprint(
375+
f"\n[green]-> User [bold]{user_id}[/bold] "
376+
f"[red]DELETED[/red] successfully."
377+
)
384378

385379

386380
@app.command()

app/managers/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class ResponseMessages:
3333
CANT_GENERATE_RESET = "Unable to generate the Password Reset Token"
3434
INVALID_TOKEN = "That token is Invalid" # noqa: S105
3535
EXPIRED_TOKEN = "That token has Expired" # noqa: S105
36-
VERIFICATION_SUCCESS = "User succesfully Verified"
36+
VERIFICATION_SUCCESS = "User successfully Verified"
3737
USER_NOT_FOUND = "User not Found"
3838
ALREADY_VALIDATED = "You are already validated"
3939
VALIDATION_RESENT = "Validation email re-sent"

app/managers/user.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from email_validator import EmailNotValidError, validate_email
88
from fastapi import BackgroundTasks, HTTPException, status
99
from pydantic import NameEmail
10-
from sqlalchemy import Select, delete, or_, select, update
10+
from sqlalchemy import Select, delete, func, or_, select, update
1111
from sqlalchemy.exc import IntegrityError
1212

1313
from app.config.settings import get_settings
@@ -21,6 +21,7 @@
2121
)
2222
from app.managers.auth import AuthManager
2323
from app.managers.email import EmailManager
24+
from app.models.enums import RoleType
2425
from app.models.user import User
2526
from app.schemas.email import EmailTemplateSchema
2627
from app.schemas.request.user import SearchField
@@ -31,7 +32,6 @@
3132

3233
from sqlalchemy.ext.asyncio import AsyncSession
3334

34-
from app.models.enums import RoleType
3535
from app.schemas.request.user import (
3636
UserChangePasswordRequest,
3737
UserEditRequest,
@@ -46,6 +46,7 @@ class ErrorMessages:
4646
AUTH_INVALID = "Wrong email or password"
4747
USER_INVALID = "This User does not exist"
4848
CANT_SELF_BAN = "You cannot ban/unban yourself!"
49+
CANT_DELETE_LAST_ADMIN = "Cannot delete the last admin user"
4950
NOT_VERIFIED = "You need to verify your Email before logging in"
5051
EMPTY_FIELDS = "You must supply all fields and they cannot be empty"
5152
ALREADY_BANNED_OR_UNBANNED = "This User is already banned/unbanned"
@@ -187,12 +188,32 @@ async def login(
187188

188189
@staticmethod
189190
async def delete_user(user_id: int, session: AsyncSession) -> None:
190-
"""Delete the User with specified ID."""
191+
"""Delete the User with specified ID.
192+
193+
Prevents deletion of the last admin user to avoid system lockout.
194+
"""
191195
check_user = await get_user_by_id_(user_id, session)
192196
if not check_user:
193197
raise HTTPException(
194198
status.HTTP_404_NOT_FOUND, ErrorMessages.USER_INVALID
195199
)
200+
201+
# Prevent deletion if user is the last admin
202+
if check_user.role == RoleType.admin:
203+
count_query = (
204+
select(func.count())
205+
.select_from(User)
206+
.where(User.role == RoleType.admin)
207+
)
208+
result = await session.execute(count_query)
209+
admin_count = result.scalar()
210+
211+
if admin_count is not None and admin_count <= 1:
212+
raise HTTPException(
213+
status.HTTP_400_BAD_REQUEST,
214+
ErrorMessages.CANT_DELETE_LAST_ADMIN,
215+
)
216+
196217
await session.execute(delete(User).where(User.id == user_id))
197218

198219
@staticmethod

app/resources/user.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ async def edit_user(
150150
status_code=status.HTTP_204_NO_CONTENT,
151151
)
152152
async def delete_user(
153-
user_id: int, db: Annotated[AsyncSession, Depends(get_database)]
153+
user_id: int,
154+
db: Annotated[AsyncSession, Depends(get_database)],
154155
) -> None:
155156
"""Delete the specified User by user_id.
156157

docs/reference/api.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Verifies a user's email address using the token sent via email.
135135

136136
```json
137137
{
138-
"detail": "User succesfully Verified"
138+
"detail": "User successfully Verified"
139139
}
140140
```
141141

@@ -574,10 +574,24 @@ Permanently delete the specified user.
574574

575575
**Response:** `204 No Content`
576576

577+
**Error Responses:**
578+
579+
- `400 Bad Request`: Returned when attempting to delete the last admin user
580+
581+
```json
582+
{
583+
"detail": "Cannot delete the last admin user"
584+
}
585+
```
586+
577587
**Notes:**
578588

579589
- This action is permanent and cannot be undone
580590
- All associated API keys will also be deleted
591+
- **Admin Protection:** The system prevents the last admin from deleting
592+
themselves to avoid system lockout. Admins can delete themselves only when
593+
multiple admin users exist
594+
- Regular users can always be deleted by admins regardless of admin count
581595

582596
---
583597

docs/usage/user-control.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,19 @@ $ api-admin user delete 23
132132
They will no longer be able to access the API, this CANNOT BE UNDONE. It's
133133
probably better to BAN a user unless you are very sure.
134134

135+
!!! warning "Admin Protection"
136+
137+
The system includes safeguards to prevent you from locking yourself out
138+
of the API by removing all admin users.
139+
140+
When using the API directly, the system prevents the last remaining admin
141+
user from deleting **themselves**.
142+
143+
When using the CLI (`api-admin user delete`), the CLI prevents deletion
144+
of the last remaining admin user altogether to avoid system lockout.
145+
To delete an admin user, you must first ensure there is at least one
146+
other admin user in the system.
147+
135148
## Seed Database with Users from a File
136149

137150
You can import users from a CSV file using the `db seed` command:

tests/cli/test_cli_create_user.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_create_user_success(
5757
],
5858
)
5959
assert result.exit_code == 0
60-
assert "added succesfully" in result.output
60+
assert "added successfully" in result.output
6161
assert mock_register.called
6262

6363
def test_create_admin_success(
@@ -85,7 +85,7 @@ def test_create_admin_success(
8585
],
8686
)
8787
assert result.exit_code == 0
88-
assert "added succesfully" in result.output
88+
assert "added successfully" in result.output
8989
assert "Admin" in result.output
9090
assert mock_register.called
9191

@@ -159,7 +159,7 @@ def test_create_user_interactive(
159159

160160
result = runner.invoke(app, ["user", "create"], input=user_input)
161161
assert result.exit_code == 0
162-
assert "added succesfully" in result.output
162+
assert "added successfully" in result.output
163163
assert mock_register.called
164164

165165
def test_create_admin_interactive(
@@ -178,6 +178,6 @@ def test_create_admin_interactive(
178178

179179
result = runner.invoke(app, ["user", "create"], input=user_input)
180180
assert result.exit_code == 0
181-
assert "added succesfully" in result.output
181+
assert "added successfully" in result.output
182182
assert "Admin" in result.output
183183
assert mock_register.called

tests/cli/test_cli_user_command.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -561,40 +561,29 @@ def test_admin_missing_user(
561561
# ------------------------------------------------------------------------ #
562562
def test_delete_user(self, runner: CliRunner, mocker, test_user) -> None:
563563
"""Test that the 'delete' command works."""
564-
mock_session = mocker.patch(
565-
self.patch_async_session,
566-
)
567-
568-
mock_session.return_value.__aenter__.return_value.get.return_value = (
569-
test_user
564+
mock_manager = mocker.patch(
565+
"app.commands.user.UserManager.delete_user",
566+
return_value=None,
570567
)
568+
mocker.patch(self.patch_async_session)
571569

572570
result = runner.invoke(app, ["user", "delete", str(test_user.id)])
573571
assert result.exit_code == 0
574572

575-
assert mock_session.called
576-
assert mock_session.return_value.__aenter__.return_value.commit.called
577-
573+
assert mock_manager.called
578574
assert f"User {test_user.id} DELETED" in result.output
579575

580576
def test_delete_sqlalchemy_error(
581577
self, runner: CliRunner, mocker, test_user
582578
) -> None:
583579
"""Test that the 'delete' command exits when there is an error."""
584-
mock_session = mocker.patch(
585-
self.patch_async_session,
586-
)
587-
mock_session.return_value.__aenter__.return_value.get.side_effect = (
588-
SQLAlchemyError("Ooooops!!")
589-
)
590-
result = runner.invoke(app, ["user", "delete", str(test_user.id)])
591-
assert result.exit_code == 1
592-
593-
assert mock_session.called
594-
assert (
595-
not mock_session.return_value.__aenter__.return_value.commit.called
580+
mocker.patch(
581+
"app.commands.user.UserManager.delete_user",
582+
side_effect=SQLAlchemyError("Ooooops!!"),
596583
)
584+
mocker.patch(self.patch_async_session)
597585

586+
result = runner.invoke(app, ["user", "delete", str(test_user.id)])
598587
assert result.exit_code == 1
599588

600589
assert "ERROR deleting that User" in result.output
@@ -604,23 +593,39 @@ def test_delete_missing_user(
604593
self, runner: CliRunner, mocker, test_user
605594
) -> None:
606595
"""Test that the 'delete' command exits when the user is missing."""
607-
mock_session = mocker.patch(
608-
self.patch_async_session,
609-
)
610-
mock_session.return_value.__aenter__.return_value.get.return_value = (
611-
None
596+
mocker.patch(
597+
"app.commands.user.UserManager.delete_user",
598+
side_effect=HTTPException(
599+
status_code=status.HTTP_404_NOT_FOUND,
600+
detail=ErrorMessages.USER_INVALID,
601+
),
612602
)
603+
mocker.patch(self.patch_async_session)
613604

614605
result = runner.invoke(app, ["user", "delete", str(test_user.id)])
615606
assert result.exit_code == 1
616607

617-
assert mock_session.called
618-
assert (
619-
not mock_session.return_value.__aenter__.return_value.commit.called
608+
assert "ERROR deleting that User" in result.output
609+
assert ErrorMessages.USER_INVALID in result.output
610+
611+
def test_delete_last_admin_blocked(
612+
self, runner: CliRunner, mocker, test_admin
613+
) -> None:
614+
"""Test that deleting the last admin is blocked."""
615+
mocker.patch(
616+
"app.commands.user.UserManager.delete_user",
617+
side_effect=HTTPException(
618+
status_code=status.HTTP_400_BAD_REQUEST,
619+
detail=ErrorMessages.CANT_DELETE_LAST_ADMIN,
620+
),
620621
)
622+
mocker.patch(self.patch_async_session)
623+
624+
result = runner.invoke(app, ["user", "delete", str(test_admin.id)])
625+
assert result.exit_code == 1
621626

622627
assert "ERROR deleting that User" in result.output
623-
assert "User not found" in result.output
628+
assert ErrorMessages.CANT_DELETE_LAST_ADMIN in result.output
624629

625630
# ------------------------------------------------------------------------ #
626631
# test 'search' subcommand #

tests/integration/test_auth_routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ async def test_verify_user(
428428
)
429429

430430
assert response.status_code == status.HTTP_200_OK
431-
assert response.json()["detail"] == "User succesfully Verified"
431+
assert response.json()["detail"] == "User successfully Verified"
432432

433433
@pytest.mark.parametrize(
434434
"verification_token",

0 commit comments

Comments
 (0)