Skip to content

Commit 9103858

Browse files
fix: handle WebSocket connections in audit log exception handlers
1 parent 42e8606 commit 9103858

3 files changed

Lines changed: 65 additions & 13 deletions

File tree

src/apps/audit/fields.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
11
from asgi_correlation_id.context import correlation_id
22
from ddtrace.trace import tracer
3-
from fastapi import Request
43
from fastapi.routing import APIRoute
54
from starlette.exceptions import HTTPException as StarletteHTTPException
5+
from starlette.requests import HTTPConnection, Request
66

77
from apps.shared.exception import BaseError
88

99
from .enums import EventOutcome
1010

1111

12-
def http_audit_fields(request: Request, error: BaseError | StarletteHTTPException | None = None) -> dict:
13-
"""Audit fields derived from HTTP request/error."""
14-
route = request.scope.get("route")
12+
def http_audit_fields(conn: HTTPConnection, error: BaseError | StarletteHTTPException | None = None) -> dict:
13+
"""Audit fields derived from an HTTP or WebSocket connection and error."""
14+
route = conn.scope.get("route")
1515
span = tracer.current_span()
1616
fields = {
17-
"client_ip": request.client and request.client.host,
17+
"client_ip": conn.client and conn.client.host,
1818
"http_request_id": correlation_id.get(),
19-
"http_request_method": request.method,
19+
# WebSocket connections have no HTTP method.
20+
"http_request_method": conn.method if isinstance(conn, Request) else None,
2021
"http_response_status_code": isinstance(route, APIRoute) and route.status_code or 200,
21-
"url_path": request.url.path,
22-
"url_query": request.url.query or None,
23-
"user_agent": request.headers.get("user-agent"),
22+
"url_path": conn.url.path,
23+
"url_query": conn.url.query or None,
24+
"user_agent": conn.headers.get("user-agent"),
2425
"trace_id": span and str(span.trace_id),
2526
}
2627
if error is not None:

src/apps/authentication/tests/test_auth.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import pytest
88
from pytest_mock import MockerFixture
99
from sqlalchemy.ext.asyncio import AsyncSession
10+
from starlette.types import Message
11+
from starlette.websockets import WebSocket
1012

1113
from apps.audit import EventAction, EventOutcome
1214
from apps.authentication.domain.login import UserLoginRequest
@@ -25,6 +27,7 @@
2527
from apps.users.cruds.user import UsersCRUD
2628
from apps.users.domain import User, UserCreate, UserCreateRequest
2729
from config import settings
30+
from infrastructure.http.exceptions import session_token_invalid_error_handler
2831

2932
TEST_PASSWORD = "Test12345!"
3033

@@ -113,6 +116,50 @@ async def test_user_not_found(self, client: TestClient, mocker: MockerFixture):
113116
assert resp.status_code == http.HTTPStatus.UNAUTHORIZED
114117
assert resp.json()["result"][0]["message"] == SessionTokenInvalidError.message
115118

119+
async def test_ws_session_invalid_audit_event(self, mocker: MockerFixture):
120+
"""A `SessionTokenInvalidError` raised from a WebSocket connection (e.g. `/ws/alerts`) must
121+
122+
log the `user:session:invalid` audit event without crashing. `http_audit_fields` used to
123+
read the HTTP-only `request.method`, which a `WebSocket` lacks (regression, M2-10698).
124+
"""
125+
audit_log = mocker.patch("infrastructure.http.exceptions.log")
126+
user_id = uuid.uuid4()
127+
128+
async def receive() -> Message:
129+
return {"type": "websocket.connect"}
130+
131+
async def send(message: Message) -> None:
132+
return None
133+
134+
websocket = WebSocket(
135+
{
136+
"type": "websocket",
137+
"scheme": "ws",
138+
"server": ("test.com", 80),
139+
"path": "/ws/alerts",
140+
"query_string": b"",
141+
"root_path": "",
142+
"headers": [(b"host", b"test.com"), (b"user-agent", b"pytest-ws-client")],
143+
"client": ("10.1.2.3", 54321),
144+
},
145+
receive=receive,
146+
send=send,
147+
)
148+
error = SessionTokenInvalidError(user_id=user_id)
149+
150+
resp = await session_token_invalid_error_handler(websocket, error)
151+
152+
audit_log.assert_awaited_once()
153+
event = audit_log.call_args[0][0]
154+
assert event.event_action == EventAction.USER_SESSION_INVALID
155+
assert event.event_outcome == EventOutcome.FAILURE
156+
assert event.user_id == user_id
157+
assert event.http_request_method is None # WebSocket has no HTTP method
158+
assert event.url_path == "/ws/alerts"
159+
assert event.client_ip == "10.1.2.3"
160+
assert event.user_agent == "pytest-ws-client"
161+
assert resp.status_code == http.HTTPStatus.UNAUTHORIZED
162+
116163
async def test_delete_access_token(self, client: TestClient, user: User, mocker: MockerFixture):
117164
audit_log = mocker.patch("apps.authentication.api.auth.log")
118165
client.login(user)

src/infrastructure/http/exceptions.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from fastapi.exceptions import RequestValidationError
66
from starlette import status
77
from starlette.exceptions import HTTPException as StarletteHTTPException
8-
from starlette.requests import Request
8+
from starlette.requests import HTTPConnection, Request
99
from starlette.responses import JSONResponse, Response
1010

1111
from apps.audit import AuditEvent, EventAction, http_audit_fields, log
@@ -21,7 +21,7 @@ def _set_trace_exception(exc: Exception) -> None:
2121
span.set_exc_info(type(exc), exc, exc.__traceback__)
2222

2323

24-
def custom_base_errors_handler(_: Request, error: BaseError) -> JSONResponse:
24+
def custom_base_errors_handler(_: HTTPConnection, error: BaseError) -> JSONResponse:
2525
"""This function is called if the BaseError was raised."""
2626

2727
logger.error(error.error, exc_info=error)
@@ -53,8 +53,12 @@ def custom_base_errors_handler(_: Request, error: BaseError) -> JSONResponse:
5353
)
5454

5555

56-
async def session_token_invalid_error_handler(request: Request, error: SessionTokenInvalidError) -> JSONResponse:
57-
"""user:session:invalid audit event on 401 from invalid session token in `Authorization` header."""
56+
async def session_token_invalid_error_handler(request: HTTPConnection, error: SessionTokenInvalidError) -> JSONResponse:
57+
"""user:session:invalid audit event on 401 from invalid session token.
58+
59+
``request`` may be a ``WebSocket`` (e.g. the `/ws/alerts` handshake) as well as an HTTP
60+
``Request``, so only fields common to both connection types may be used here.
61+
"""
5862
await log(
5963
AuditEvent(
6064
event_action=EventAction.USER_SESSION_INVALID,

0 commit comments

Comments
 (0)