Skip to content

Commit 24dedf9

Browse files
igennovaigennovapre-commit-ci[bot]
authored
Add DELETE /users/{id} with no-resources rule (#317)
Fix : #194 # Description Adds a new `DELETE /users/{user_id}` endpoint (Phase 1) so users can delete their own OpenML account, and administrators can delete any account, as long as the account has no uploaded resources (datasets, flows, runs, studies). ## Phase 1 - `204 No Content` — account deleted successfully - `401 Unauthorized` — no / invalid API key - `403 Forbidden` — non-admin tries to delete another user's account - `404 Not Found` — `user_id` does not exist - `409 Conflict` — user still has uploaded datasets / flows / runs / studies (RFC 9457 `AccountHasResourcesError`) --------- Co-authored-by: igennova <luckynegi025@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ea2cf8f commit 24dedf9

5 files changed

Lines changed: 340 additions & 0 deletions

File tree

src/core/errors.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,22 @@ class ForbiddenError(ProblemDetailError):
231231
_default_status_code = HTTPStatus.FORBIDDEN
232232

233233

234+
class UserNotFoundError(ProblemDetailError):
235+
"""Raised when a user id does not exist in the user database."""
236+
237+
uri = "https://openml.org/problems/user-not-found"
238+
title = "User Not Found"
239+
_default_status_code = HTTPStatus.NOT_FOUND
240+
241+
242+
class AccountHasResourcesError(ProblemDetailError):
243+
"""Raised when account deletion is blocked because the user still owns resources."""
244+
245+
uri = "https://openml.org/problems/account-has-resources"
246+
title = "Account Has Active Resources"
247+
_default_status_code = HTTPStatus.CONFLICT
248+
249+
234250
# =============================================================================
235251
# Tag Errors
236252
# =============================================================================

src/database/users.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,52 @@ async def get_groups(self) -> list[UserGroup]:
9999

100100
async def is_admin(self) -> bool:
101101
return UserGroup.ADMIN in await self.get_groups()
102+
103+
104+
async def exists_by_id(*, user_id: int, connection: AsyncConnection) -> bool:
105+
row = await connection.execute(
106+
text("SELECT 1 FROM users WHERE id = :user_id LIMIT 1"),
107+
parameters={"user_id": user_id},
108+
)
109+
return row.one_or_none() is not None
110+
111+
112+
async def has_user_references(*, user_id: int, expdb: AsyncConnection) -> bool:
113+
"""Return ``True`` if any ``expdb`` row still references ``user_id``."""
114+
row = await expdb.execute(
115+
text(
116+
"""
117+
SELECT EXISTS (
118+
SELECT 1 FROM dataset WHERE uploader = :uid
119+
UNION ALL SELECT 1 FROM dataset_description WHERE uploader = :uid
120+
UNION ALL SELECT 1 FROM dataset_status WHERE user_id = :uid
121+
UNION ALL SELECT 1 FROM dataset_tag WHERE uploader = :uid
122+
UNION ALL SELECT 1 FROM dataset_topic WHERE uploader = :uid
123+
UNION ALL SELECT 1 FROM implementation WHERE uploader = :uid
124+
UNION ALL SELECT 1 FROM implementation_tag WHERE uploader = :uid
125+
UNION ALL SELECT 1 FROM `run` WHERE uploader = :uid
126+
UNION ALL SELECT 1 FROM run_study WHERE uploader = :uid
127+
UNION ALL SELECT 1 FROM run_tag WHERE uploader = :uid
128+
UNION ALL SELECT 1 FROM setup_tag WHERE uploader = :uid
129+
UNION ALL SELECT 1 FROM study WHERE creator = :uid
130+
UNION ALL SELECT 1 FROM task WHERE creator = :uid
131+
UNION ALL SELECT 1 FROM task_study WHERE uploader = :uid
132+
UNION ALL SELECT 1 FROM task_tag WHERE uploader = :uid
133+
) AS has_refs
134+
""",
135+
),
136+
parameters={"uid": user_id},
137+
)
138+
return bool(row.scalar_one())
139+
140+
141+
async def delete_user_rows(*, user_id: int, userdb: AsyncConnection) -> None:
142+
"""Remove group memberships then the user row (openml user database)."""
143+
await userdb.execute(
144+
text("DELETE FROM users_groups WHERE user_id = :user_id"),
145+
parameters={"user_id": user_id},
146+
)
147+
await userdb.execute(
148+
text("DELETE FROM users WHERE id = :user_id"),
149+
parameters={"user_id": user_id},
150+
)

src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from routers.openml.study import router as study_router
3838
from routers.openml.tasks import router as task_router
3939
from routers.openml.tasktype import router as ttype_router
40+
from routers.openml.users import router as users_router
4041

4142

4243
@asynccontextmanager
@@ -110,6 +111,7 @@ def create_api(configuration: Configuration | None = None) -> FastAPI:
110111
app.include_router(study_router)
111112
app.include_router(setup_router)
112113
app.include_router(run_router)
114+
app.include_router(users_router)
113115

114116
logger.info("App setup completed.")
115117
logger.remove(setup_sink)

src/routers/openml/users.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""User account HTTP endpoints."""
2+
3+
from http import HTTPStatus
4+
from typing import Annotated
5+
6+
from fastapi import APIRouter, Depends, Path, Response
7+
from loguru import logger
8+
from sqlalchemy.exc import IntegrityError
9+
from sqlalchemy.ext.asyncio import AsyncConnection # noqa: TC002 used at runtime by FastAPI Depends
10+
11+
import database.users
12+
from core.errors import AccountHasResourcesError, ForbiddenError, UserNotFoundError
13+
from database.users import User
14+
from routers.dependencies import expdb_connection, fetch_user_or_raise, userdb_connection
15+
16+
_ACCOUNT_HAS_RESOURCES_MSG = (
17+
"Cannot delete this account while records still reference the user "
18+
"(datasets, flows, runs, studies, tags, etc.). Remove or transfer them first."
19+
)
20+
21+
router = APIRouter(prefix="/users", tags=["users"])
22+
23+
24+
@router.delete(
25+
"/{user_id}",
26+
responses={
27+
HTTPStatus.NO_CONTENT: {"description": "User account deleted."},
28+
HTTPStatus.UNAUTHORIZED: {"description": "Authentication failed or missing."},
29+
HTTPStatus.FORBIDDEN: {"description": "Not allowed to delete this account."},
30+
HTTPStatus.NOT_FOUND: {"description": "User id not found."},
31+
HTTPStatus.CONFLICT: {
32+
"description": "User still has datasets, flows, runs, or studies.",
33+
},
34+
},
35+
)
36+
async def delete_user_account(
37+
user_id: Annotated[int, Path(description="Numeric user id to delete.", gt=0)],
38+
current_user: Annotated[User, Depends(fetch_user_or_raise)],
39+
expdb: Annotated[AsyncConnection, Depends(expdb_connection)],
40+
userdb: Annotated[AsyncConnection, Depends(userdb_connection)],
41+
) -> Response:
42+
"""Delete the user account if they have no associated resources.
43+
44+
The account to be deleted must not have associated resources (such as
45+
datasets, tasks, or tags). Users may only delete their own account.
46+
Administrators may delete any account that satisfies the no-resources rule.
47+
"""
48+
# How to handle users that do have associated resources is an ongoing discussion,
49+
# see also: https://github.com/openml/server-api/issues/194
50+
if current_user.user_id != user_id and not await current_user.is_admin():
51+
msg = "You may only delete your own user account."
52+
raise ForbiddenError(msg)
53+
54+
if not await database.users.exists_by_id(user_id=user_id, connection=userdb):
55+
msg = f"User {user_id} not found."
56+
raise UserNotFoundError(msg)
57+
58+
if await database.users.has_user_references(user_id=user_id, expdb=expdb):
59+
raise AccountHasResourcesError(_ACCOUNT_HAS_RESOURCES_MSG)
60+
61+
try:
62+
await database.users.delete_user_rows(user_id=user_id, userdb=userdb)
63+
except IntegrityError as exc:
64+
logger.error(
65+
"Delete of user {user_id} failed with integrity error after pre-check.",
66+
user_id=user_id,
67+
)
68+
raise AccountHasResourcesError(_ACCOUNT_HAS_RESOURCES_MSG) from exc
69+
70+
logger.info("User account {user_id} was removed.", user_id=user_id)
71+
return Response(status_code=HTTPStatus.NO_CONTENT)
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Tests for DELETE /users/{user_id}."""
2+
3+
import uuid
4+
from http import HTTPStatus
5+
from typing import NamedTuple
6+
7+
import httpx # noqa: TC002 used at runtime by pytest fixtures
8+
import pytest
9+
import pytest_mock # noqa: TC002 used at runtime by pytest fixtures
10+
from sqlalchemy import text
11+
from sqlalchemy.exc import IntegrityError
12+
from sqlalchemy.ext.asyncio import AsyncConnection # noqa: TC002 used at runtime by pytest fixtures
13+
14+
from core.errors import AccountHasResourcesError, ForbiddenError, UserNotFoundError
15+
from database.users import UserGroup
16+
from routers.openml.users import delete_user_account
17+
from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey
18+
19+
20+
async def test_delete_user_missing_auth(py_api: httpx.AsyncClient) -> None:
21+
response = await py_api.delete("/users/1")
22+
assert response.status_code == HTTPStatus.UNAUTHORIZED
23+
body = response.json()
24+
assert body["code"] == "103"
25+
assert body["detail"] == "No API key provided."
26+
27+
28+
class DisposableUser(NamedTuple):
29+
user_id: int
30+
api_key: str
31+
32+
33+
@pytest.fixture
34+
async def disposable_user(user_test: AsyncConnection) -> DisposableUser:
35+
api_key = uuid.uuid4().hex
36+
suffix = uuid.uuid4().hex[:10]
37+
username = f"tmp_user_{suffix}"
38+
email = f"{suffix}@openml-delete.test"
39+
40+
await user_test.execute(
41+
text(
42+
"""
43+
INSERT INTO users (
44+
ip_address, username, password, email, created_on,
45+
company, country, bio, session_hash
46+
) VALUES (
47+
'127.0.0.1', :username, 'x', :email, UNIX_TIMESTAMP(),
48+
'', '', '', :api_key
49+
)
50+
""",
51+
),
52+
parameters={"username": username, "email": email, "api_key": api_key},
53+
)
54+
uid_row = await user_test.execute(text("SELECT LAST_INSERT_ID() AS id"))
55+
(new_id,) = uid_row.one()
56+
await user_test.execute(
57+
text("INSERT INTO users_groups (user_id, group_id) VALUES (:uid, :gid)"),
58+
parameters={"uid": new_id, "gid": UserGroup.READ_WRITE.value},
59+
)
60+
return DisposableUser(user_id=new_id, api_key=api_key)
61+
# No explicit teardown: the ``user_test`` fixture rolls back at the end
62+
# of the test, which removes the rows inserted above.
63+
64+
65+
@pytest.mark.mut
66+
async def test_delete_user_api_success_self_delete(
67+
py_api: httpx.AsyncClient,
68+
user_test: AsyncConnection,
69+
disposable_user: DisposableUser,
70+
mocker: pytest_mock.MockerFixture,
71+
) -> None:
72+
log_info = mocker.patch("routers.openml.users.logger.info")
73+
74+
response = await py_api.delete(
75+
f"/users/{disposable_user.user_id}",
76+
params={"api_key": disposable_user.api_key},
77+
)
78+
assert response.status_code == HTTPStatus.NO_CONTENT
79+
assert response.content == b""
80+
81+
exists = await user_test.execute(
82+
text("SELECT 1 FROM users WHERE id = :id LIMIT 1"),
83+
parameters={"id": disposable_user.user_id},
84+
)
85+
assert exists.one_or_none() is None
86+
87+
log_info.assert_any_call(
88+
"User account {user_id} was removed.",
89+
user_id=disposable_user.user_id,
90+
)
91+
92+
93+
@pytest.mark.mut
94+
async def test_delete_user_api_success_admin_deletes_disposable_user(
95+
py_api: httpx.AsyncClient,
96+
user_test: AsyncConnection,
97+
disposable_user: DisposableUser,
98+
) -> None:
99+
response = await py_api.delete(
100+
f"/users/{disposable_user.user_id}",
101+
params={"api_key": ApiKey.ADMIN},
102+
)
103+
assert response.status_code == HTTPStatus.NO_CONTENT
104+
assert response.content == b""
105+
106+
exists = await user_test.execute(
107+
text("SELECT 1 FROM users WHERE id = :id LIMIT 1"),
108+
parameters={"id": disposable_user.user_id},
109+
)
110+
assert exists.one_or_none() is None
111+
112+
113+
# ── Direct handler tests ──
114+
115+
116+
async def test_delete_user_direct_not_found(
117+
user_test: AsyncConnection,
118+
expdb_test: AsyncConnection,
119+
) -> None:
120+
with pytest.raises(UserNotFoundError, match=r"User 888888888 not found\.") as exc_info:
121+
await delete_user_account(
122+
user_id=888888888,
123+
current_user=ADMIN_USER,
124+
expdb=expdb_test,
125+
userdb=user_test,
126+
)
127+
assert exc_info.value.status_code == HTTPStatus.NOT_FOUND
128+
assert exc_info.value.uri == UserNotFoundError.uri
129+
130+
131+
async def test_delete_user_direct_forbidden(
132+
user_test: AsyncConnection,
133+
expdb_test: AsyncConnection,
134+
) -> None:
135+
with pytest.raises(
136+
ForbiddenError, match=r"You may only delete your own user account\."
137+
) as exc_info:
138+
await delete_user_account(
139+
user_id=ADMIN_USER.user_id,
140+
current_user=SOME_USER,
141+
expdb=expdb_test,
142+
userdb=user_test,
143+
)
144+
assert exc_info.value.status_code == HTTPStatus.FORBIDDEN
145+
assert exc_info.value.uri == ForbiddenError.uri
146+
147+
admin_row = await user_test.execute(
148+
text("SELECT 1 FROM users WHERE id = :id LIMIT 1"),
149+
parameters={"id": ADMIN_USER.user_id},
150+
)
151+
assert admin_row.one_or_none() is not None
152+
153+
154+
async def test_delete_user_direct_conflict_has_resources(
155+
user_test: AsyncConnection,
156+
expdb_test: AsyncConnection,
157+
) -> None:
158+
with pytest.raises(AccountHasResourcesError, match="Cannot delete this account") as exc_info:
159+
await delete_user_account(
160+
user_id=OWNER_USER.user_id,
161+
current_user=ADMIN_USER,
162+
expdb=expdb_test,
163+
userdb=user_test,
164+
)
165+
assert exc_info.value.status_code == HTTPStatus.CONFLICT
166+
assert exc_info.value.uri == AccountHasResourcesError.uri
167+
168+
owner_row = await user_test.execute(
169+
text("SELECT 1 FROM users WHERE id = :id LIMIT 1"),
170+
parameters={"id": OWNER_USER.user_id},
171+
)
172+
assert owner_row.one_or_none() is not None
173+
174+
175+
@pytest.mark.mut
176+
async def test_delete_user_integrity_error_logs_and_raises_conflict(
177+
user_test: AsyncConnection,
178+
expdb_test: AsyncConnection,
179+
disposable_user: DisposableUser,
180+
mocker: pytest_mock.MockerFixture,
181+
) -> None:
182+
mocker.patch(
183+
"database.users.delete_user_rows",
184+
side_effect=IntegrityError(
185+
"DELETE FROM users", {"user_id": disposable_user.user_id}, Exception("fk")
186+
),
187+
)
188+
log_error = mocker.patch("routers.openml.users.logger.error")
189+
190+
with pytest.raises(AccountHasResourcesError, match="Cannot delete this account") as exc_info:
191+
await delete_user_account(
192+
user_id=disposable_user.user_id,
193+
current_user=ADMIN_USER,
194+
expdb=expdb_test,
195+
userdb=user_test,
196+
)
197+
198+
assert exc_info.value.status_code == HTTPStatus.CONFLICT
199+
log_error.assert_called_once_with(
200+
"Delete of user {user_id} failed with integrity error after pre-check.",
201+
user_id=disposable_user.user_id,
202+
)

0 commit comments

Comments
 (0)