Skip to content

Commit 2f71c65

Browse files
authored
Merge pull request #210 from umago/authorized-endpoint
Implement the /authorized endpoint
2 parents c8ed06e + 621b610 commit 2f71c65

File tree

8 files changed

+206
-4
lines changed

8 files changed

+206
-4
lines changed

src/app/endpoints/authorized.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Handler for REST API call to authorized endpoint."""
2+
3+
import asyncio
4+
import logging
5+
from typing import Any
6+
7+
from fastapi import APIRouter, Request
8+
9+
from auth import get_auth_dependency
10+
from models.responses import AuthorizedResponse, UnauthorizedResponse, ForbiddenResponse
11+
12+
logger = logging.getLogger(__name__)
13+
router = APIRouter(tags=["authorized"])
14+
auth_dependency = get_auth_dependency()
15+
16+
17+
authorized_responses: dict[int | str, dict[str, Any]] = {
18+
200: {
19+
"description": "The user is logged-in and authorized to access OLS",
20+
"model": AuthorizedResponse,
21+
},
22+
400: {
23+
"description": "Missing or invalid credentials provided by client",
24+
"model": UnauthorizedResponse,
25+
},
26+
403: {
27+
"description": "User is not authorized",
28+
"model": ForbiddenResponse,
29+
},
30+
}
31+
32+
33+
@router.post("/authorized", responses=authorized_responses)
34+
def authorized_endpoint_handler(_request: Request) -> AuthorizedResponse:
35+
"""Handle request to the /authorized endpoint."""
36+
# Ignore the user token, we should not return it in the response
37+
user_id, user_name, _ = asyncio.run(auth_dependency(_request))
38+
return AuthorizedResponse(user_id=user_id, username=user_name)

src/app/endpoints/feedback.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
from auth import get_auth_dependency
1212
from configuration import configuration
13-
from models.responses import FeedbackResponse, StatusResponse
13+
from models.responses import (
14+
FeedbackResponse,
15+
StatusResponse,
16+
UnauthorizedResponse,
17+
ForbiddenResponse,
18+
)
1419
from models.requests import FeedbackRequest
1520
from utils.suid import get_suid
1621
from utils.common import retrieve_user_id
@@ -22,6 +27,14 @@
2227
# Response for the feedback endpoint
2328
feedback_response: dict[int | str, dict[str, Any]] = {
2429
200: {"response": "Feedback received and stored"},
30+
400: {
31+
"description": "Missing or invalid credentials provided by client",
32+
"model": UnauthorizedResponse,
33+
},
34+
403: {
35+
"description": "User is not authorized",
36+
"model": ForbiddenResponse,
37+
},
2538
}
2639

2740

src/app/endpoints/query.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from client import LlamaStackClientHolder
2525
from configuration import configuration
26-
from models.responses import QueryResponse
26+
from models.responses import QueryResponse, UnauthorizedResponse, ForbiddenResponse
2727
from models.requests import QueryRequest, Attachment
2828
import constants
2929
from auth import get_auth_dependency
@@ -45,6 +45,14 @@
4545
"conversation_id": "123e4567-e89b-12d3-a456-426614174000",
4646
"response": "LLM ansert",
4747
},
48+
400: {
49+
"description": "Missing or invalid credentials provided by client",
50+
"model": UnauthorizedResponse,
51+
},
52+
403: {
53+
"description": "User is not authorized",
54+
"model": ForbiddenResponse,
55+
},
4856
503: {
4957
"detail": {
5058
"response": "Unable to connect to Llama Stack",

src/app/routers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
config,
1212
feedback,
1313
streaming_query,
14+
authorized,
1415
)
1516

1617

@@ -28,3 +29,4 @@ def include_routers(app: FastAPI) -> None:
2829
app.include_router(config.router, prefix="/v1")
2930
app.include_router(feedback.router, prefix="/v1")
3031
app.include_router(streaming_query.router, prefix="/v1")
32+
app.include_router(authorized.router, prefix="/v1")

src/models/responses.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,59 @@ class StatusResponse(BaseModel):
242242
]
243243
}
244244
}
245+
246+
247+
class AuthorizedResponse(BaseModel):
248+
"""Model representing a response to an authorization request.
249+
250+
Attributes:
251+
user_id: The ID of the logged in user.
252+
username: The name of the logged in user.
253+
"""
254+
255+
user_id: str
256+
username: str
257+
258+
# provides examples for /docs endpoint
259+
model_config = {
260+
"json_schema_extra": {
261+
"examples": [
262+
{
263+
"user_id": "123e4567-e89b-12d3-a456-426614174000",
264+
"username": "user1",
265+
}
266+
]
267+
}
268+
}
269+
270+
271+
class UnauthorizedResponse(BaseModel):
272+
"""Model representing response for missing or invalid credentials."""
273+
274+
detail: str
275+
276+
# provides examples for /docs endpoint
277+
model_config = {
278+
"json_schema_extra": {
279+
"examples": [
280+
{
281+
"detail": "Unauthorized: No auth header found",
282+
},
283+
]
284+
}
285+
}
286+
287+
288+
class ForbiddenResponse(UnauthorizedResponse):
289+
"""Model representing response for forbidden access."""
290+
291+
# provides examples for /docs endpoint
292+
model_config = {
293+
"json_schema_extra": {
294+
"examples": [
295+
{
296+
"detail": "Forbidden: User is not authorized to access this resource",
297+
},
298+
]
299+
}
300+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from unittest.mock import AsyncMock
2+
3+
import pytest
4+
from fastapi import Request, HTTPException
5+
6+
from app.endpoints.authorized import authorized_endpoint_handler
7+
8+
9+
def test_authorized_endpoint(mocker):
10+
"""Test the authorized endpoint handler."""
11+
# Mock the auth dependency to return a user ID and username
12+
auth_dependency_mock = AsyncMock()
13+
auth_dependency_mock.return_value = ("test-id", "test-user", None)
14+
mocker.patch(
15+
"app.endpoints.authorized.auth_dependency", side_effect=auth_dependency_mock
16+
)
17+
18+
request = Request(
19+
scope={
20+
"type": "http",
21+
"query_string": b"",
22+
}
23+
)
24+
25+
response = authorized_endpoint_handler(request)
26+
27+
assert response.model_dump() == {
28+
"user_id": "test-id",
29+
"username": "test-user",
30+
}
31+
32+
33+
def test_authorized_unauthorized(mocker):
34+
"""Test the authorized endpoint handler with a custom user ID."""
35+
auth_dependency_mock = AsyncMock()
36+
auth_dependency_mock.side_effect = HTTPException(
37+
status_code=403, detail="User is not authorized"
38+
)
39+
mocker.patch(
40+
"app.endpoints.authorized.auth_dependency", side_effect=auth_dependency_mock
41+
)
42+
43+
request = Request(
44+
scope={
45+
"type": "http",
46+
"query_string": b"",
47+
}
48+
)
49+
50+
with pytest.raises(HTTPException) as exc_info:
51+
authorized_endpoint_handler(request)
52+
53+
assert exc_info.value.status_code == 403
54+
assert exc_info.value.detail == "User is not authorized"

tests/unit/app/test_routers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
config,
1414
feedback,
1515
streaming_query,
16+
authorized,
1617
) # noqa:E402
1718

1819

@@ -34,7 +35,7 @@ def test_include_routers() -> None:
3435
include_routers(app)
3536

3637
# are all routers added?
37-
assert len(app.routers) == 8
38+
assert len(app.routers) == 9
3839
assert root.router in app.routers
3940
assert info.router in app.routers
4041
assert models.router in app.routers
@@ -43,3 +44,4 @@ def test_include_routers() -> None:
4344
assert config.router in app.routers
4445
assert feedback.router in app.routers
4546
assert streaming_query.router in app.routers
47+
assert authorized.router in app.routers

tests/unit/models/test_responses.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
from models.responses import QueryResponse, StatusResponse
1+
from models.responses import (
2+
QueryResponse,
3+
StatusResponse,
4+
AuthorizedResponse,
5+
UnauthorizedResponse,
6+
)
27

38

49
class TestQueryResponse:
@@ -28,3 +33,27 @@ def test_constructor(self) -> None:
2833
sr = StatusResponse(functionality="feedback", status={"enabled": True})
2934
assert sr.functionality == "feedback"
3035
assert sr.status == {"enabled": True}
36+
37+
38+
class TestAuthorizedResponse:
39+
"""Test cases for the AuthorizedResponse model."""
40+
41+
def test_constructor(self) -> None:
42+
"""Test the AuthorizedResponse constructor."""
43+
ar = AuthorizedResponse(
44+
user_id="123e4567-e89b-12d3-a456-426614174000",
45+
username="testuser",
46+
)
47+
assert ar.user_id == "123e4567-e89b-12d3-a456-426614174000"
48+
assert ar.username == "testuser"
49+
50+
51+
class TestUnauthorizedResponse:
52+
"""Test cases for the UnauthorizedResponse model."""
53+
54+
def test_constructor(self) -> None:
55+
"""Test the UnauthorizedResponse constructor."""
56+
ur = UnauthorizedResponse(
57+
detail="Missing or invalid credentials provided by client"
58+
)
59+
assert ur.detail == "Missing or invalid credentials provided by client"

0 commit comments

Comments
 (0)