From 877aa1029730d54b0cdbede8b6cd32b85d8c6d94 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 11:34:10 +1000 Subject: [PATCH 1/4] feat: delete_user method for Auth0Client --- auth0/client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/auth0/client.py b/auth0/client.py index 87827725..9363c7ad 100644 --- a/auth0/client.py +++ b/auth0/client.py @@ -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. From 67144640ed7e17eb1822e3001b4cbad5c4404df9 Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 11:38:39 +1000 Subject: [PATCH 2/4] feat: hard-delete method for BiocommonsUser --- db/models.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/db/models.py b/db/models.py index 4438c81f..cc8488e7 100644 --- a/db/models.py +++ b/db/models.py @@ -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 @@ -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. From d7459036a57699a13f0be8d7a3c17590a3fb959e Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 11:42:54 +1000 Subject: [PATCH 3/4] feat: allow hard-delete in db-admin (dev-aaf env only) --- db/st_admin.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/db/st_admin.py b/db/st_admin.py index 07aacb36..e0400107 100644 --- a/db/st_admin.py +++ b/db/st_admin.py @@ -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"] From 16a690709b581bd4594f41843ce179909d8dfedc Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Fri, 11 Sep 2026 11:45:25 +1000 Subject: [PATCH 4/4] test: unit tests of hard-delete --- tests/db/test_db_admin.py | 65 +++++++++++++++++++++++++++++++++++ tests/db/test_models.py | 71 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/tests/db/test_db_admin.py b/tests/db/test_db_admin.py index 1a9b5d87..368f1279 100644 --- a/tests/db/test_db_admin.py +++ b/tests/db/test_db_admin.py @@ -23,6 +23,7 @@ GroupMembershipView, PlatformMembershipHistoryView, PlatformMembershipView, + UserView, ) from db.types import ApprovalStatusEnum, PlatformEnum from tests.db.datagen import ( @@ -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 diff --git a/tests/db/test_models.py b/tests/db/test_models.py index bceb7dfe..fdc09894 100644 --- a/tests/db/test_models.py +++ b/tests/db/test_models.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from unittest.mock import ANY, call import pytest @@ -16,6 +16,7 @@ BiocommonsGroup, BiocommonsUser, BiocommonsUserHistory, + EmailChangeOtp, GroupMembership, GroupMembershipHistory, Platform, @@ -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