Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions auth0/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@ def check_user_password(self, email: str, password: str, settings: Settings) ->
resp.raise_for_status()
return True

def delete_user(self, user_id: str) -> None:
"""
Permanently delete a user from Auth0.
"""
url = f"{self.api_base}/users/{user_id}"
resp = self._client.delete(url)
resp.raise_for_status()

def delete_user_refresh_tokens(self, user_id: str) -> bool:
"""
Delete all refresh tokens for a user.
Expand Down
39 changes: 38 additions & 1 deletion db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from httpx import HTTPStatusError
from pydantic import AwareDatetime
from sqlalchemy import Column, Index, String, Text, UniqueConstraint, desc, func
from sqlalchemy import Column, Index, String, Text, UniqueConstraint, delete, desc, func
from sqlmodel import DateTime, Field, Relationship, Session, select
from sqlmodel import Enum as DbEnum
from starlette.exceptions import HTTPException
Expand Down Expand Up @@ -254,6 +254,43 @@ def delete(self, session: Session, commit: bool = False, reason: str | None = No
super().delete(session, commit=commit)
return self

def hard_delete(self, session: Session, auth0_client: Auth0Client) -> None:
"""
Permanently delete the user and their own rows from the DB, and delete
the user from Auth0. For testing use only - irreversible.

Refuses if this user acted as an admin on another user's record
(appears as deleted_by/updated_by there) - that history would be lost.
"""
user_id = self.id
other_admin_actions = (
session.exec(select(BiocommonsUser.id).execution_options(include_deleted=True).where(
BiocommonsUser.deleted_by_id == user_id, BiocommonsUser.id != user_id)).first()
or session.exec(select(BiocommonsUserHistory.id).execution_options(include_deleted=True).where(
BiocommonsUserHistory.updated_by_id == user_id, BiocommonsUserHistory.user_id != user_id)).first()
or session.exec(select(PlatformMembership.id).execution_options(include_deleted=True).where(
PlatformMembership.updated_by_id == user_id, PlatformMembership.user_id != user_id)).first()
or session.exec(select(PlatformMembershipHistory.id).execution_options(include_deleted=True).where(
PlatformMembershipHistory.updated_by_id == user_id, PlatformMembershipHistory.user_id != user_id)).first()
or session.exec(select(GroupMembership.id).execution_options(include_deleted=True).where(
GroupMembership.updated_by_id == user_id, GroupMembership.user_id != user_id)).first()
or session.exec(select(GroupMembershipHistory.id).execution_options(include_deleted=True).where(
GroupMembershipHistory.updated_by_id == user_id, GroupMembershipHistory.user_id != user_id)).first()
)
if other_admin_actions:
raise ValueError(f"User {user_id} has acted as an admin on another user's record and cannot be hard-deleted")

logger.info(f"Deleting user {user_id} from Auth0")
try:
auth0_client.delete_user(user_id=user_id)
except HTTPStatusError as e:
logger.warning(f"Error deleting user {user_id} from Auth0: {e}")

for model in (EmailChangeOtp, GroupMembershipHistory, PlatformMembershipHistory, GroupMembership, PlatformMembership, BiocommonsUserHistory):
session.exec(delete(model).where(model.user_id == user_id))
session.exec(delete(BiocommonsUser).where(BiocommonsUser.id == user_id))
session.commit()

def update_from_auth0(self, auth0_id: str, auth0_client: Auth0Client) -> Self:
"""
Fetch user data from Auth0 and update this object with it.
Expand Down
38 changes: 38 additions & 0 deletions db/st_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,44 @@ async def repr(self, obj: Any, request: Request) -> str:
return f"❌ {obj.email} (deleted)"
return obj.email

async def is_row_action_allowed(self, request: Request, name: str) -> bool:
if name == "hard_delete_user":
return get_settings().environment == "dev-aaf"
return await super().is_row_action_allowed(request, name)

@row_action(
name="hard_delete_user",
text="Hard Delete (permanent)",
confirmation="This permanently deletes the user and ALL related data from the database and Auth0. This cannot be undone. Continue?",
icon_class="fa fa-skull-crossbones",
submit_btn_text="Delete Permanently",
submit_btn_class="btn btn-danger",
action_btn_class="btn btn-danger",
)
async def hard_delete_row_action(self, request: Request, pk: Any) -> Any:
settings = get_settings()
if settings.environment != "dev-aaf":
raise HTTPException(status_code=403, detail="Hard delete is only available in the dev-aaf environment")
management_token = get_management_token(settings=settings)
auth0_client_gen = get_auth0_client(settings=settings, management_token=management_token)
auth0_client = next(auth0_client_gen)
db_session_gen = get_db_session()
db_session = next(db_session_gen)
try:
user = BiocommonsUser.get_by_id(pk, db_session, include_deleted=True)
if user is None:
raise HTTPException(status_code=404, detail=f"User {pk} not found")
logger.info(f"Hard deleting user {pk}")
try:
user.hard_delete(db_session, auth0_client=auth0_client)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return "User permanently deleted"
finally:
auth0_client_gen.close()
db_session_gen.close()
db_session.close()


class DeletedUserView(UserView):
fields = ["email", "username", "deleted_at", HasOne("deleted_by", identity="user"), "deletion_reason"]
Expand Down
65 changes: 65 additions & 0 deletions tests/db/test_db_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
GroupMembershipView,
PlatformMembershipHistoryView,
PlatformMembershipView,
UserView,
)
from db.types import ApprovalStatusEnum, PlatformEnum
from tests.db.datagen import (
Expand Down Expand Up @@ -485,3 +486,67 @@ def test_platform_membership_history_view_list_query_includes_deleted_users(
# Verify the related deleted user can be resolved from the history row
matching_history = next(row for row in results if row.id == history.id)
assert matching_history.user.id == deleted_user.id


@pytest.mark.asyncio
async def test_is_row_action_allowed_hard_delete_requires_dev_aaf(mocker, mock_settings):
mocker.patch("db.st_admin.get_settings", return_value=mock_settings)
view = UserView(BiocommonsUser)

mock_settings.environment = "dev-aaf"
assert await view.is_row_action_allowed(MagicMock(), "hard_delete_user") is True

mock_settings.environment = "dev"
assert await view.is_row_action_allowed(MagicMock(), "hard_delete_user") is False


@pytest.mark.asyncio
async def test_hard_delete_row_action_calls_hard_delete(mocker, mock_settings):
mock_settings.environment = "dev-aaf"
mock_auth0_client = MagicMock()
mock_db_session = MagicMock()
target_user_id = "auth0|user456"
mock_user = MagicMock(spec=BiocommonsUser)

mocker.patch("db.st_admin.get_db_session", return_value=_yield_once(mock_db_session))
mocker.patch("db.st_admin.get_auth0_client", return_value=_yield_once(mock_auth0_client))
mocker.patch("db.st_admin.get_management_token")
mocker.patch("db.st_admin.get_settings", return_value=mock_settings)
mocker.patch.object(BiocommonsUser, "get_by_id", return_value=mock_user)

view = UserView(BiocommonsUser)
response = await view.hard_delete_row_action(MagicMock(), target_user_id)

assert response == "User permanently deleted"
mock_user.hard_delete.assert_called_once_with(mock_db_session, auth0_client=mock_auth0_client)


@pytest.mark.asyncio
async def test_hard_delete_row_action_blocked_outside_dev_aaf(mocker, mock_settings):
mock_settings.environment = "dev"
mocker.patch("db.st_admin.get_settings", return_value=mock_settings)
view = UserView(BiocommonsUser)

with pytest.raises(HTTPException) as exc:
await view.hard_delete_row_action(MagicMock(), "auth0|user456")
assert exc.value.status_code == 403


@pytest.mark.asyncio
async def test_hard_delete_row_action_returns_400_on_value_error(mocker, mock_settings):
mock_settings.environment = "dev-aaf"
mock_auth0_client = MagicMock()
mock_db_session = MagicMock()
mock_user = MagicMock(spec=BiocommonsUser)
mock_user.hard_delete.side_effect = ValueError("nope")

mocker.patch("db.st_admin.get_db_session", return_value=_yield_once(mock_db_session))
mocker.patch("db.st_admin.get_auth0_client", return_value=_yield_once(mock_auth0_client))
mocker.patch("db.st_admin.get_management_token")
mocker.patch("db.st_admin.get_settings", return_value=mock_settings)
mocker.patch.object(BiocommonsUser, "get_by_id", return_value=mock_user)

view = UserView(BiocommonsUser)
with pytest.raises(HTTPException) as exc:
await view.hard_delete_row_action(MagicMock(), "auth0|user456")
assert exc.value.status_code == 400
71 changes: 70 additions & 1 deletion tests/db/test_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from unittest.mock import ANY, call

import pytest
Expand All @@ -16,6 +16,7 @@
BiocommonsGroup,
BiocommonsUser,
BiocommonsUserHistory,
EmailChangeOtp,
GroupMembership,
GroupMembershipHistory,
Platform,
Expand Down Expand Up @@ -1647,6 +1648,74 @@ def test_biocommons_user_admin_restore_creates_history(test_db_session, mock_aut
assert history.updated_by_id == admin.id


def test_hard_delete_removes_user_and_related_rows(test_db_session, mock_auth0_client, persistent_factories):
user = BiocommonsUserFactory.create_sync()
platform = PlatformFactory.create_sync(id=PlatformEnum.GALAXY)
group = BiocommonsGroupFactory.create_sync()
platform_membership = PlatformMembershipFactory.create_sync(user=user, platform=platform)
group_membership = GroupMembershipFactory.create_sync(user=user, group=group)
test_db_session.commit()

platform_history = platform_membership.save_history(test_db_session)
group_history = group_membership.save_history(test_db_session)
user_history = user.save_history(test_db_session, change="test")
otp = EmailChangeOtp(
user_id=user.id,
target_email="new@example.com",
otp_hash="hash",
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
test_db_session.add(otp)
test_db_session.commit()

user_id = user.id
platform_membership_id = platform_membership.id
group_membership_id = group_membership.id
platform_history_id = platform_history.id
group_history_id = group_history.id
user_history_id = user_history.id
otp_id = otp.id

user.hard_delete(test_db_session, auth0_client=mock_auth0_client)

mock_auth0_client.delete_user.assert_called_once_with(user_id=user_id)

test_db_session.expire_all()
assert test_db_session.get(BiocommonsUser, user_id) is None
assert BiocommonsUser.get_deleted_by_id(test_db_session, user_id) is None
assert test_db_session.get(PlatformMembership, platform_membership_id) is None
assert test_db_session.get(GroupMembership, group_membership_id) is None
assert test_db_session.get(PlatformMembershipHistory, platform_history_id) is None
assert test_db_session.get(GroupMembershipHistory, group_history_id) is None
assert test_db_session.get(BiocommonsUserHistory, user_history_id) is None
assert test_db_session.get(EmailChangeOtp, otp_id) is None


def test_hard_delete_refuses_if_user_acted_as_admin_for_another_user(test_db_session, mock_auth0_client, persistent_factories):
admin = BiocommonsUserFactory.create_sync()
other_user = BiocommonsUserFactory.create_sync()
other_user.delete(test_db_session, deleted_by=admin, commit=True)

with pytest.raises(ValueError):
admin.hard_delete(test_db_session, auth0_client=mock_auth0_client)

mock_auth0_client.delete_user.assert_not_called()
assert test_db_session.get(BiocommonsUser, admin.id) is not None


def test_hard_delete_allows_user_who_only_admined_themselves(test_db_session, mock_auth0_client, persistent_factories):
user = BiocommonsUserFactory.create_sync()
user_id = user.id
user.delete(test_db_session, deleted_by=user, commit=True)
user = BiocommonsUser.get_deleted_by_id(test_db_session, user_id)

user.hard_delete(test_db_session, auth0_client=mock_auth0_client)

mock_auth0_client.delete_user.assert_called_once_with(user_id=user_id)
test_db_session.expire_all()
assert test_db_session.get(BiocommonsUser, user_id) is None


def test_admin_delete_preserves_memberships(test_db_session, persistent_factories, mock_auth0_client):
"""
Test group and platform memberships are preserved when admin soft-deletes a user, so
Expand Down