Skip to content

Commit 3f1a7ac

Browse files
authored
Improve error messages for user authentication (#297)
(work in progress, but I want to run CI) Improve error messages for user authentication by differentiating between failed authentication (of a provided key) or required authentication (if no key is provided but one is required). Previous behavior would be inconsistent depending on endpoint. For user-optional endpoints a mistake with the apikey would fail silently (if it passed the constraints of ApiKey), which may be hard to spot as a user. For user required endpoints there would always be an AuthenticationFailed. And that is if dependencies were used. WIP because I have to update the sites that use `fetch_user` where `fetch_user_or_raise` should be used.
1 parent 7b96b39 commit 3f1a7ac

12 files changed

Lines changed: 59 additions & 59 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,7 @@ jobs:
4242
run: |
4343
marker="${{ matrix.php_api == true && 'php_api' || 'not php_api' }} and ${{ matrix.mutations == true && 'mut' || 'not mut' }}"
4444
jobs="${{ matrix.mutations == true && '1' || 'auto' }}"
45-
docker compose exec python-api coverage run -m pytest -n "$jobs" -v -m "$marker"
46-
- name: Produce coverage report
47-
run: |
48-
docker compose exec python-api coverage combine
49-
docker compose exec python-api coverage xml
45+
docker compose exec python-api pytest -n "$jobs" -v -m "$marker" --cov --cov-report=xml
5046
- name: Upload results to Codecov
5147
uses: codecov/codecov-action@v4
5248
with:

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
[project.optional-dependencies]
2828
dev = [
2929
"coverage",
30+
"pytest-cov",
3031
"pre-commit",
3132
"pytest",
3233
"pytest-mock",
@@ -47,8 +48,6 @@ docs = [
4748

4849
[tool.coverage.run]
4950
branch=true
50-
concurrency=["multiprocessing"]
51-
parallel=true
5251

5352
[tool.coverage.report]
5453
show_missing=true

src/core/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ class AuthenticationRequiredError(ProblemDetailError):
186186
uri = "https://openml.org/problems/authentication-required"
187187
title = "Authentication Required"
188188
_default_status_code = HTTPStatus.UNAUTHORIZED
189+
_default_code = 103 # PHP API doesn't differentiate
189190

190191

191192
class AuthenticationFailedError(ProblemDetailError):

src/routers/dependencies.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pydantic import BaseModel
66
from sqlalchemy.ext.asyncio import AsyncConnection
77

8-
from core.errors import AuthenticationFailedError
8+
from core.errors import AuthenticationFailedError, AuthenticationRequiredError
99
from database.setup import expdb_database, user_database
1010
from database.users import APIKey, User
1111

@@ -26,15 +26,22 @@ async def fetch_user(
2626
api_key: APIKey | None = None,
2727
user_data: Annotated[AsyncConnection | None, Depends(userdb_connection)] = None,
2828
) -> User | None:
29-
return await User.fetch(api_key, user_data) if api_key and user_data else None
29+
if not (api_key and user_data):
30+
return None
31+
32+
user = await User.fetch(api_key, user_data)
33+
if user:
34+
return user
35+
msg = "Invalid API key provided."
36+
raise AuthenticationFailedError(msg)
3037

3138

3239
def fetch_user_or_raise(
3340
user: Annotated[User | None, Depends(fetch_user)] = None,
3441
) -> User:
3542
if user is None:
36-
msg = "Authentication failed"
37-
raise AuthenticationFailedError(msg)
43+
msg = "No API key provided."
44+
raise AuthenticationRequiredError(msg)
3845
return user
3946

4047

src/routers/openml/datasets.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import database.qualities
1414
from core.access import _user_has_access
1515
from core.errors import (
16-
AuthenticationRequiredError,
1716
DatasetAdminOnlyError,
1817
DatasetNoAccessError,
1918
DatasetNoDataFileError,
@@ -338,13 +337,9 @@ async def get_dataset_features(
338337
async def update_dataset_status(
339338
dataset_id: Annotated[int, Body()],
340339
status: Annotated[Literal[DatasetStatus.ACTIVE, DatasetStatus.DEACTIVATED], Body()],
341-
user: Annotated[User | None, Depends(fetch_user)],
340+
user: Annotated[User, Depends(fetch_user_or_raise)],
342341
expdb: Annotated[AsyncConnection, Depends(expdb_connection)],
343342
) -> dict[str, str | int]:
344-
if user is None:
345-
msg = "Updating dataset status requires authentication."
346-
raise AuthenticationRequiredError(msg)
347-
348343
dataset = await _get_dataset_raise_otherwise(dataset_id, user, expdb)
349344

350345
can_deactivate = dataset.uploader == user.user_id or await user.is_admin()

src/routers/openml/study.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from core.formatting import _str_to_bool
2020
from database.users import User
21-
from routers.dependencies import expdb_connection, fetch_user
21+
from routers.dependencies import expdb_connection, fetch_user, fetch_user_or_raise
2222
from schemas.core import Visibility
2323
from schemas.study import CreateStudy, Study, StudyStatus, StudyType
2424

@@ -62,7 +62,7 @@ class AttachDetachResponse(BaseModel):
6262
async def attach_to_study(
6363
study_id: Annotated[int, Body()],
6464
entity_ids: Annotated[list[int], Body()],
65-
user: Annotated[User | None, Depends(fetch_user)] = None,
65+
user: Annotated[User, Depends(fetch_user_or_raise)],
6666
expdb: Annotated[AsyncConnection, Depends(expdb_connection)] = None,
6767
) -> AttachDetachResponse:
6868
assert expdb is not None # noqa: S101
@@ -99,13 +99,10 @@ async def attach_to_study(
9999
@router.post("/")
100100
async def create_study(
101101
study: CreateStudy,
102-
user: Annotated[User | None, Depends(fetch_user)] = None,
102+
user: Annotated[User, Depends(fetch_user_or_raise)],
103103
expdb: Annotated[AsyncConnection, Depends(expdb_connection)] = None,
104104
) -> dict[Literal["study_id"], int]:
105105
assert expdb is not None # noqa: S101
106-
if user is None:
107-
msg = "Creating a study requires authentication."
108-
raise AuthenticationRequiredError(msg)
109106
if study.main_entity_type == StudyType.RUN and study.tasks:
110107
msg = "Cannot create a run study with tasks."
111108
raise StudyInvalidTypeError(msg)

tests/dependencies/__init__.py

Whitespace-only changes.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import pytest
2+
from sqlalchemy.ext.asyncio import AsyncConnection
3+
4+
from core.errors import AuthenticationFailedError, AuthenticationRequiredError
5+
from database.users import User
6+
from routers.dependencies import fetch_user, fetch_user_or_raise
7+
from tests.users import ADMIN_USER, OWNER_USER, SOME_USER, ApiKey
8+
9+
10+
@pytest.mark.parametrize(
11+
("api_key", "user"),
12+
[
13+
(ApiKey.ADMIN, ADMIN_USER),
14+
(ApiKey.OWNER_USER, OWNER_USER),
15+
(ApiKey.SOME_USER, SOME_USER),
16+
],
17+
)
18+
async def test_fetch_user(api_key: str, user: User, user_test: AsyncConnection) -> None:
19+
db_user = await fetch_user(api_key, user_data=user_test)
20+
assert isinstance(db_user, User)
21+
assert user.user_id == db_user.user_id
22+
assert set(await user.get_groups()) == set(await db_user.get_groups())
23+
24+
25+
async def test_fetch_user_no_key_no_user() -> None:
26+
assert await fetch_user(api_key=None) is None
27+
28+
29+
async def test_fetch_user_invalid_key_raises(user_test: AsyncConnection) -> None:
30+
with pytest.raises(AuthenticationFailedError):
31+
await fetch_user(api_key=ApiKey.INVALID, user_data=user_test)
32+
33+
34+
async def test_fetch_user_or_raise_raises_if_no_user() -> None:
35+
# This function calls `fetch_user` through dependency injection,
36+
# so it only needs to correctly handle possible output of `fetch_user`.
37+
with pytest.raises(AuthenticationRequiredError):
38+
fetch_user_or_raise(user=None)

tests/routers/openml/migration/datasets_migration_test.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,16 +118,10 @@ async def test_error_unknown_dataset(
118118
assert error["detail"].startswith("No dataset")
119119

120120

121-
@pytest.mark.parametrize(
122-
"api_key",
123-
[None, ApiKey.INVALID],
124-
)
125121
async def test_private_dataset_no_user_no_access(
126122
py_api: httpx.AsyncClient,
127-
api_key: str | None,
128123
) -> None:
129-
query = f"?api_key={api_key}" if api_key else ""
130-
response = await py_api.get(f"/datasets/130{query}")
124+
response = await py_api.get("/datasets/130")
131125

132126
# New response is 403: Forbidden instead of 412: PRECONDITION FAILED
133127
assert response.status_code == HTTPStatus.FORBIDDEN

tests/routers/openml/setups_tag_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ async def test_setup_tag_missing_auth(py_api: httpx.AsyncClient) -> None:
1414
response = await py_api.post("/setup/tag", json={"setup_id": 1, "tag": "test_tag"})
1515
assert response.status_code == HTTPStatus.UNAUTHORIZED
1616
assert response.json()["code"] == "103"
17-
assert response.json()["detail"] == "Authentication failed"
17+
assert response.json()["detail"] == "No API key provided."
1818

1919

2020
@pytest.mark.mut

0 commit comments

Comments
 (0)