Skip to content

Commit e190701

Browse files
Log rejected invalid requests as INVALID_REQUEST
1 parent 38a3b93 commit e190701

9 files changed

Lines changed: 158 additions & 10 deletions

File tree

‎app/errors/handlers.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
from typing import no_type_check
33

4+
import gfmodules.logging as gflog
45
from fastapi import FastAPI, Request
56
from fastapi.exceptions import RequestValidationError
67
from fastapi.responses import JSONResponse
@@ -9,6 +10,8 @@
910

1011
from app.errors.fhir import FHIRError
1112
from app.errors.mapping import DEFAULT_SPEC, EXCEPTION_MAP, UNEXPECTED_ERROR_MESSAGE, spec_for
13+
from app.logging.context import bind_auth_claims
14+
from app.logging.events import Log
1215
from app.models.fhir.resources.operation_outcome.resource import (
1316
OperationOutcome,
1417
OperationOutcomeDetail,
@@ -41,10 +44,26 @@ def log_rejection(status_code: int, exc: Exception) -> None:
4144
)
4245

4346

47+
def log_invalid_request(status_code: int, reason: str) -> None:
48+
gflog.emit(
49+
logger,
50+
Log.INVALID_REQUEST,
51+
"Invalid request rejected",
52+
fields={"http_status": status_code, "error_reason": reason},
53+
)
54+
55+
56+
def _log_invalid_request(request: Request, status_code: int, exc: Exception) -> None:
57+
with bind_auth_claims(getattr(request.state, "auth", None)):
58+
log_invalid_request(status_code, _summarize_reason(exc))
59+
60+
4461
def handle_mapped_exception(request: Request, exc: Exception) -> JSONResponse:
4562
spec = spec_for(exc)
4663
status_code = spec.http_status
4764
log_rejection(status_code, exc)
65+
if spec.invalid_input:
66+
_log_invalid_request(request, status_code, exc)
4867

4968
if "fhir" in request.url.path:
5069
fhir_error = FHIRError(severity=spec.severity, code=spec.fhir_code, msg=str(exc))
@@ -61,6 +80,7 @@ def handle_request_validation_exception(req: Request, exc: RequestValidationErro
6180
path = req.url.path
6281
status_code = 422
6382
log_rejection(status_code, exc)
83+
_log_invalid_request(req, status_code, exc)
6484
if "fhir" in path:
6585
issues = []
6686

‎app/errors/mapping.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ErrorSpec(BaseModel):
2020
fhir_code: str
2121
http_status: int
2222
log: bool = False
23+
invalid_input: bool = False
2324

2425

2526
# `spec_for` resolves an exception through its MRO, so a subclass picks up its base
@@ -30,11 +31,11 @@ class ErrorSpec(BaseModel):
3031
ForbiddenError: ErrorSpec(severity="error", fhir_code="forbidden", http_status=403),
3132
UnauthorizedError: ErrorSpec(severity="error", fhir_code="security", http_status=403),
3233
InvalidKeyInfoError: ErrorSpec(severity="error", fhir_code="transient", http_status=503),
33-
InvalidModelError: ErrorSpec(severity="error", fhir_code="structure", http_status=400),
34+
InvalidModelError: ErrorSpec(severity="error", fhir_code="structure", http_status=400, invalid_input=True),
3435
InvalidHeaderPropertyError: ErrorSpec(severity="error", fhir_code="invalid", http_status=401),
35-
PseudonymError: ErrorSpec(severity="error", fhir_code="invalid", http_status=422),
36+
PseudonymError: ErrorSpec(severity="error", fhir_code="invalid", http_status=422, invalid_input=True),
3637
PseudonymExchangeError: ErrorSpec(severity="error", fhir_code="invalid", http_status=400, log=True),
37-
ValueError: ErrorSpec(severity="error", fhir_code="invalid", http_status=400, log=True),
38+
ValueError: ErrorSpec(severity="error", fhir_code="invalid", http_status=400, invalid_input=True),
3839
}
3940

4041
# Applied to any exception without a mapped type (rendered as HTTP 500). Unmapped

‎app/logging/events.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ class Log(_Base):
4242
_APP: ("exception_type", "table", "column", "value_length", "column_limit"),
4343
},
4444
)
45+
INVALID_REQUEST = LogEvent( # NVI-SYS-007
46+
"100607",
47+
logging.WARNING,
48+
(_APP, _SIEM),
49+
{
50+
_APP: (*_CALLER, "http_status", "error_reason"),
51+
_SIEM: (*_CALLER, "http_status", "error_reason"),
52+
},
53+
)
4554

4655
REGISTERED_REFERRAL = LogEvent( # NVI-REF-001
4756
"900400",

‎tests/errors/test_handlers.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
InvalidKeyInfoError,
1919
InvalidModelError,
2020
NotFoundError,
21+
PseudonymError,
22+
PseudonymExchangeError,
2123
UnauthorizedScopeError,
2224
UnauthorizedSourceError,
2325
)
@@ -181,3 +183,34 @@ def test_logs_the_unhandled_exception_event(self) -> None:
181183
method="GET",
182184
)
183185
assert entry.record.exc_info is not None
186+
187+
188+
class TestInvalidRequestLogging:
189+
def test_logs_a_request_validation_error(self) -> None:
190+
with capture_records() as records:
191+
_client().get("/rest/validated")
192+
193+
assert_event_emitted(
194+
records, Log.INVALID_REQUEST, http_status=422, error_reason="query.required: Field required"
195+
)
196+
197+
@pytest.mark.parametrize(
198+
("exc", "status"),
199+
[(ValueError("bad"), 400), (PseudonymError("bad"), 422), (InvalidModelError("bad"), 400)],
200+
)
201+
def test_logs_invalid_input(self, exc: Exception, status: int) -> None:
202+
_pending["exc"] = exc
203+
with capture_records() as records:
204+
_client().get("/rest/boom")
205+
206+
assert_event_emitted(records, Log.INVALID_REQUEST, http_status=status)
207+
208+
@pytest.mark.parametrize(
209+
"exc", [NotFoundError(), PseudonymExchangeError("rejected"), UnauthorizedSourceError(), RuntimeError("boom")]
210+
)
211+
def test_does_not_log_other_failures_as_invalid_requests(self, exc: Exception) -> None:
212+
_pending["exc"] = exc
213+
with capture_records() as records:
214+
_client().get("/rest/boom")
215+
216+
assert records.for_event(Log.INVALID_REQUEST) == []

‎tests/errors/test_mapping.py‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from app.errors.mapping import ErrorSpec, should_log, spec_for
3+
from app.errors.mapping import DEFAULT_SPEC, EXCEPTION_MAP, ErrorSpec, should_log, spec_for
44
from app.models.auth.data import AuthorizationScope
55
from app.services.exceptions import (
66
ConflictError,
@@ -17,8 +17,10 @@
1717
)
1818

1919

20-
def _spec(fhir_code: str, http_status: int, *, log: bool = False) -> ErrorSpec:
21-
return ErrorSpec(severity="error", fhir_code=fhir_code, http_status=http_status, log=log)
20+
def _spec(fhir_code: str, http_status: int, *, log: bool = False, invalid_input: bool = False) -> ErrorSpec:
21+
return ErrorSpec(
22+
severity="error", fhir_code=fhir_code, http_status=http_status, log=log, invalid_input=invalid_input
23+
)
2224

2325

2426
def _scope_error() -> UnauthorizedScopeError:
@@ -33,10 +35,14 @@ def _scope_error() -> UnauthorizedScopeError:
3335
pytest.param(ForbiddenError(), _spec("forbidden", 403), id="forbidden"),
3436
pytest.param(InvalidKeyInfoError(), _spec("transient", 503), id="invalid-key-info"),
3537
pytest.param(InvalidHeaderPropertyError("aud", "x"), _spec("invalid", 401), id="invalid-header-property"),
36-
pytest.param(PseudonymError(), _spec("invalid", 422), id="pseudonym"),
37-
pytest.param(ValueError("boom"), _spec("invalid", 400, log=True), id="value-error"),
38+
pytest.param(PseudonymError(), _spec("invalid", 422, invalid_input=True), id="pseudonym"),
39+
pytest.param(ValueError("boom"), _spec("invalid", 400, invalid_input=True), id="value-error"),
3840
pytest.param(PseudonymExchangeError("rejected"), _spec("invalid", 400, log=True), id="pseudonym-exchange"),
39-
pytest.param(InvalidModelError("bad"), _spec("structure", 400), id="invalid-model-outranks-value-error"),
41+
pytest.param(
42+
InvalidModelError("bad"),
43+
_spec("structure", 400, invalid_input=True),
44+
id="invalid-model-outranks-value-error",
45+
),
4046
pytest.param(_scope_error(), _spec("security", 403), id="unauthorized-scope-via-mro"),
4147
pytest.param(UnauthorizedManagingRequestError(), _spec("security", 403), id="unauthorized-managing-via-mro"),
4248
pytest.param(UnauthorizedUraError(), _spec("security", 403), id="unauthorized-ura-via-mro"),
@@ -51,7 +57,7 @@ def test_spec_for_resolves_the_expected_spec(exc: Exception, expected: ErrorSpec
5157
("exc", "expected"),
5258
[
5359
pytest.param(PseudonymError(), False, id="pseudonym"),
54-
pytest.param(ValueError("boom"), True, id="value-error"),
60+
pytest.param(ValueError("boom"), False, id="value-error"),
5561
pytest.param(PseudonymExchangeError("rejected"), True, id="pseudonym-exchange"),
5662
pytest.param(RuntimeError("unexpected"), True, id="unmapped"),
5763
pytest.param(NotFoundError(), False, id="not-found"),
@@ -61,3 +67,8 @@ def test_spec_for_resolves_the_expected_spec(exc: Exception, expected: ErrorSpec
6167
)
6268
def test_should_log(exc: Exception, expected: bool) -> None:
6369
assert should_log(exc) is expected
70+
71+
72+
def test_no_spec_is_both_a_failure_and_invalid_input() -> None:
73+
for spec in [*EXCEPTION_MAP.values(), DEFAULT_SPEC]:
74+
assert not (spec.log and spec.invalid_input)

‎tests/logging/test_events.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def test_defines_every_required_event(self) -> None:
2828
("SYS_UNHANDLED_EXCEPTION", "100604"),
2929
("DB_SCHEMA_ERROR", "100605"),
3030
("SYS_MISSING_CORRELATION_ID", "100606"),
31+
("INVALID_REQUEST", "100607"),
3132
("REGISTERED_REFERRAL", "900400"),
3233
("IDEMPOTENT_REGISTRATION", "900401"),
3334
("REFERRAL_SEARCHED_ON_ID", "900402"),

‎tests/routers/test_localization_list.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import pytest
44
from fastapi.testclient import TestClient
5+
from gfmodules.logging.testing import assert_event_emitted, capture_records
56

67
from app.debug.crypto_service_api_client_mock import CryptoServiceApiClientMock
8+
from app.logging.events import Log
79
from app.models.auth.data import AuthorizationScope
810
from app.models.pseudonym import EncryptedPseudonym
911
from app.models.ura import UraNumber
@@ -84,6 +86,29 @@ def test_rejects_source_that_differs_from_claims(self, client: TestClient) -> No
8486

8587
assert response.status_code == 403
8688

89+
@pytest.mark.parametrize(
90+
"path",
91+
[
92+
("extension", 0, "valueReference", "identifier", "system"),
93+
("source", "identifier", "system"),
94+
],
95+
ids=["ura-system", "device-system"],
96+
)
97+
def test_logs_a_list_with_a_wrong_naming_system_as_an_invalid_request(
98+
self, client: TestClient, path: tuple[str | int, ...]
99+
) -> None:
100+
body = _body()
101+
node: Any = body
102+
for key in path[:-1]:
103+
node = node[key]
104+
node[path[-1]] = "urn:wrong"
105+
106+
with capture_records() as records:
107+
response = client.post("/fhir/List", json=body, headers=FHIR_JSON)
108+
109+
assert response.status_code == 400
110+
assert_event_emitted(records, Log.INVALID_REQUEST, organization_identifier=TEST_URA, http_status=400)
111+
87112
def test_requires_create_scope(
88113
self,
89114
referral_service: ReferralService,
@@ -169,6 +194,14 @@ def test_localize_query_needs_no_source_match(self, localize_client: TestClient)
169194
assert response.status_code == 200
170195
assert response.json()["resourceType"] == "Bundle"
171196

197+
def test_keeps_the_subject_token_out_of_an_invalid_localization_log(self, localize_client: TestClient) -> None:
198+
with capture_records() as records:
199+
response = localize_client.get("/fhir/List", params={"subject:identifier": "leaky-token-value"})
200+
201+
assert response.status_code == 422
202+
entry = assert_event_emitted(records, Log.INVALID_REQUEST, http_status=422)
203+
assert "leaky-token-value" not in entry.message["error_reason"]
204+
172205
def test_localize_query_requires_localize_scope(self, client: TestClient) -> None:
173206
response = client.get("/fhir/List", params={"subject:identifier": TEST_PSEUDONYM_TOKEN})
174207

‎tests/routers/test_localize.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,14 @@ def test_logs_a_failed_localization_with_the_callers_claims(
131131
http_status=400,
132132
)
133133

134+
def test_logs_an_invalid_body_as_an_invalid_request(self, localize_client: TestClient) -> None:
135+
with capture_records() as records:
136+
response = localize_client.post("/localize", json={})
137+
138+
assert response.status_code == 422
139+
assert_event_emitted(records, Log.INVALID_REQUEST, organization_identifier=TEST_URA, http_status=422)
140+
assert records.for_event(Log.LOCALIZATION_FAILED) == []
141+
134142
def test_requires_localize_scope(
135143
self,
136144
referral_service: ReferralService,

‎tests/routers/test_registrations.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ def test_invalid_request_body_is_not_a_failed_registration(self, source_client:
116116
assert response.status_code == 422
117117
assert records.for_event(Log.REFERRAL_REGISTRATION_FAILED) == []
118118

119+
def test_logs_an_invalid_body_as_an_invalid_request(self, source_client: TestClient) -> None:
120+
with capture_records() as records:
121+
response = source_client.post("/registrations", json={})
122+
123+
assert response.status_code == 422
124+
assert_event_emitted(records, Log.INVALID_REQUEST, organization_identifier=TEST_URA, http_status=422)
125+
126+
def test_logs_malformed_json_as_an_invalid_request(self, source_client: TestClient) -> None:
127+
with capture_records() as records:
128+
response = source_client.post("/registrations", content=b"{", headers={"Content-Type": "application/json"})
129+
130+
assert response.status_code == 422
131+
assert_event_emitted(records, Log.INVALID_REQUEST, http_status=422)
132+
133+
def test_logs_a_rejected_exchange_only_as_a_failed_registration(
134+
self,
135+
source_client: TestClient,
136+
crypto_client: CryptoServiceApiClientMock,
137+
key_info_service: KeyInfoService,
138+
monkeypatch: pytest.MonkeyPatch,
139+
) -> None:
140+
key_info_service.add_one("nvi-label", mechanism="AES_CBC")
141+
rejected = PseudonymExchangeError("Invalid pseudonym or oprf_key")
142+
monkeypatch.setattr(crypto_client, "exchange", Mock(side_effect=rejected))
143+
144+
with capture_records() as records:
145+
response = source_client.post("/registrations", json={"pseudonym": "pseu", "oprf_key": "key1"})
146+
147+
assert response.status_code == 400
148+
assert len(records.for_event(Log.REFERRAL_REGISTRATION_FAILED)) == 1
149+
assert records.for_event(Log.INVALID_REQUEST) == []
150+
119151
def test_requires_create_scope(
120152
self,
121153
referral_service: ReferralService,

0 commit comments

Comments
 (0)