Skip to content

Commit bd4146b

Browse files
Use one OprfPseudonym model for all pseudonym input (#615)
* Use OprfPseudonym as the single pseudonym input type - Remove PseudonymToken and BlindedPseudonym, the resolver and crypto client take an OprfPseudonym - Decode FHIR pseudonym tokens in the FHIR service layer and name the field in the error - Treat a malformed token as input validation: 422 and a rejection log, no failure event * OprfPseudonym reads the API's pseudonym and oprf_key names through validation aliases. That makes RegistrationQueryParams, CreateRegistrationRequest and LocalizeRequest redundant. * more DRY * sonar issue fixed * Decode FHIR pseudonym tokens in LocalizationListService * Accept only the API field names for OprfPseudonym * refactor: validate FHIR pseudonyms in model --------- Co-authored-by: Rick Lambrechts <rick@rl-webdiensten.nl>
1 parent 3118aaf commit bd4146b

26 files changed

Lines changed: 387 additions & 238 deletions

‎app/debug/crypto_service_api_client_mock.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
from app.models.pseudonym import PseudonymResponse
1+
from app.models.pseudonym import OprfPseudonym, PseudonymResponse
22
from app.services.crypto_service_api_client import CryptoServiceApiClient
33

44

55
class CryptoServiceApiClientMock(CryptoServiceApiClient):
66
def __init__(self) -> None:
77
"""Mock implementation of CryptoServiceApiClient for dev/testing purposes."""
88

9-
def exchange(self, jwe: str, blind_factor: str, label: str, mechanism: str) -> PseudonymResponse:
9+
def exchange(self, pseudonym: OprfPseudonym, label: str, mechanism: str) -> PseudonymResponse:
1010
return PseudonymResponse(
11-
encrypted_pseudonym=f"{jwe}:{blind_factor}",
11+
encrypted_pseudonym=f"{pseudonym.jwe}:{pseudonym.blind_factor}",
1212
iv="abcdefghijklmnop",
1313
)
1414

‎app/errors/handlers.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def handle_mapped_exception(request: Request, exc: Exception) -> JSONResponse:
5050
fhir_error = FHIRError(severity=spec.severity, code=spec.fhir_code, msg=str(exc))
5151
return JSONResponse(
5252
status_code=status_code,
53-
content=fhir_error.outcome.model_dump(exclude_none=True),
53+
content=fhir_error.outcome.model_dump(by_alias=True, exclude_none=True),
5454
headers=fhir_error.headers,
5555
)
5656

‎app/errors/mapping.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ErrorSpec(BaseModel):
3131
InvalidKeyInfoError: ErrorSpec(severity="error", fhir_code="transient", http_status=503),
3232
InvalidModelError: ErrorSpec(severity="error", fhir_code="structure", http_status=400),
3333
InvalidHeaderPropertyError: ErrorSpec(severity="error", fhir_code="invalid", http_status=401),
34-
PseudonymError: ErrorSpec(severity="error", fhir_code="invalid", http_status=422, log=True),
34+
PseudonymError: ErrorSpec(severity="error", fhir_code="invalid", http_status=422),
3535
ValueError: ErrorSpec(severity="error", fhir_code="invalid", http_status=400, log=True),
3636
}
3737

‎app/models/fhir/resources/localization_list/request.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import logging
2-
from typing import Any
2+
from typing import Annotated, Any
33

44
from fastapi import Query
5-
from pydantic import BaseModel, field_validator
5+
from pydantic import BaseModel, ValidateAs, field_validator
66

77
from app.models.fhir.elements import Identifier
88
from app.models.fhir.resources.data import (
99
DEVICE_SYSTEM,
1010
PSEUDONYM_SYSTEM,
1111
)
12-
from app.models.pseudonym import PseudonymToken
12+
from app.models.pseudonym import OprfPseudonym
13+
from app.services.exceptions import PseudonymError
14+
from app.services.fhir.pseudonym_token import decode_pseudonym_token
1315

1416
logger = logging.getLogger(__name__)
1517

@@ -42,10 +44,17 @@ def _create_openapi_examples(system: str) -> dict[str, Any]:
4244
}
4345

4446

47+
def _subject_from_query(value: str) -> OprfPseudonym:
48+
try:
49+
return decode_pseudonym_token(value, SUBJECT_IDENTIFIER_PARAM)
50+
except PseudonymError as e:
51+
raise ValueError(str(e)) from e
52+
53+
4554
class LocalizationListParams(BaseModel):
46-
model_config = {"extra": "forbid"}
55+
model_config = {"extra": "forbid", "hide_input_in_errors": True}
4756

48-
subject: str | None = Query(
57+
subject: Annotated[OprfPseudonym, ValidateAs(str, _subject_from_query)] | None = Query(
4958
alias=SUBJECT_IDENTIFIER_PARAM,
5059
openapi_examples=_create_openapi_examples(PSEUDONYM_SYSTEM),
5160
default=None,
@@ -91,9 +100,6 @@ def empty(self) -> bool:
91100
def is_localize_params(self) -> bool:
92101
return self.subject is not None and self.source is None
93102

94-
def subject_token(self) -> PseudonymToken | None:
95-
return PseudonymToken(self.subject) if self.subject else None
96-
97103

98104
class LocalizationListSearchParams(LocalizationListParams):
99105
"""Search parameters for a List search, which also controls the page that is returned."""

‎app/models/pseudonym.py‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,19 @@
11
from dataclasses import dataclass
22
from typing import Any, Self
33

4-
from pydantic import BaseModel
4+
from pydantic import BaseModel, ConfigDict, Field
55

66

77
class PseudonymResponse(BaseModel):
88
encrypted_pseudonym: str
99
iv: str
1010

1111

12-
@dataclass(frozen=True)
13-
class OprfPseudonym:
14-
"""Used in the REST routers"""
12+
class OprfPseudonym(BaseModel):
13+
model_config = ConfigDict(frozen=True)
1514

16-
jwe: str
17-
blind_factor: str
18-
19-
20-
@dataclass(frozen=True)
21-
class PseudonymToken:
22-
"""Used in the FHIR routers"""
23-
24-
value: str
25-
26-
27-
BlindedPseudonym = OprfPseudonym | PseudonymToken
15+
jwe: str = Field(alias="pseudonym")
16+
blind_factor: str = Field(alias="oprf_key")
2817

2918

3019
@dataclass

‎app/models/registrations.py‎

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,9 @@
55
from pydantic import BaseModel, field_validator
66

77
from app.db.models.referral import ReferralEntity
8-
from app.models.pseudonym import OprfPseudonym
98
from app.models.ura import UraNumber
109

1110

12-
class RegistrationQueryParams(BaseModel):
13-
pseudonym: str
14-
oprf_key: str
15-
16-
def oprf_pseudonym(self) -> OprfPseudonym:
17-
return OprfPseudonym(jwe=self.pseudonym, blind_factor=self.oprf_key)
18-
19-
20-
class CreateRegistrationRequest(RegistrationQueryParams):
21-
pass
22-
23-
24-
class LocalizeRequest(RegistrationQueryParams):
25-
pass
26-
27-
2811
class Registration(BaseModel):
2912
ura_number: str
3013
source_id: str

‎app/routers/localize.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from app.dependencies import get_pseudonym_referral_service
66
from app.models.auth.context import AuthContext
77
from app.models.auth.data import AuthorizationScope
8-
from app.models.registrations import LocalizeRequest, Registration
8+
from app.models.pseudonym import OprfPseudonym
9+
from app.models.registrations import Registration
910
from app.routers.dependencies import require_scope
1011
from app.services.pseudonym_referral_service import PseudonymReferralService
1112

@@ -14,10 +15,10 @@
1415

1516
@router.post("")
1617
def localize(
17-
data: Annotated[LocalizeRequest, Body()],
18+
pseudonym: Annotated[OprfPseudonym, Body()],
1819
pseudonym_referral_service: Annotated[PseudonymReferralService, Depends(get_pseudonym_referral_service)],
1920
ctx: Annotated[AuthContext, Depends(require_scope(AuthorizationScope.LOCALIZE))],
2021
) -> Any:
21-
_, results = pseudonym_referral_service.localize(data.oprf_pseudonym(), ctx.claims.ura_number)
22+
_, results = pseudonym_referral_service.localize(pseudonym, ctx.claims.ura_number)
2223

2324
return [Registration.from_entity(r) for r in results]

‎app/routers/registrations.py‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@
55
from app.dependencies import get_pseudonym_referral_service
66
from app.models.auth.context import AuthContext
77
from app.models.auth.data import AuthorizationScope
8-
from app.models.registrations import (
9-
CreateRegistrationRequest,
10-
Registration,
11-
RegistrationList,
12-
RegistrationQueryParams,
13-
)
8+
from app.models.pseudonym import OprfPseudonym
9+
from app.models.registrations import Registration, RegistrationList
1410
from app.routers.dependencies import require_managing_source, require_scope
1511
from app.services.pseudonym_referral_service import PseudonymReferralService
1612

@@ -22,10 +18,10 @@
2218
)
2319
def get_registration(
2420
pseudonym_referral_service: Annotated[PseudonymReferralService, Depends(get_pseudonym_referral_service)],
25-
params: Annotated[RegistrationQueryParams, Query()],
21+
pseudonym: Annotated[OprfPseudonym, Query()],
2622
ctx: Annotated[AuthContext, Depends(require_scope(AuthorizationScope.READ))],
2723
) -> Any:
28-
_, results = pseudonym_referral_service.query(ctx.claims.ura_number, pseudonym=params.oprf_pseudonym())
24+
_, results = pseudonym_referral_service.query(ctx.claims.ura_number, pseudonym=pseudonym)
2925

3026
return RegistrationList.from_entities(results)
3127

@@ -36,14 +32,14 @@ def get_registration(
3632
responses={200: {"description": "The registration already existed and is returned unchanged"}},
3733
)
3834
def add_registration(
39-
data: Annotated[CreateRegistrationRequest, Body()],
35+
pseudonym: Annotated[OprfPseudonym, Body()],
4036
response: Response,
4137
pseudonym_referral_service: Annotated[PseudonymReferralService, Depends(get_pseudonym_referral_service)],
4238
ctx: Annotated[AuthContext, Depends(require_scope(AuthorizationScope.CREATE))],
4339
source: Annotated[str, Depends(require_managing_source)],
4440
) -> Any:
4541
referral, created = pseudonym_referral_service.register(
46-
data.oprf_pseudonym(),
42+
pseudonym,
4743
ura_number=ctx.claims.ura_number,
4844
source=source,
4945
)
@@ -54,14 +50,14 @@ def add_registration(
5450

5551
@router.delete("")
5652
def delete_registration(
57-
params: Annotated[RegistrationQueryParams, Query()],
53+
pseudonym: Annotated[OprfPseudonym, Query()],
5854
pseudonym_referral_service: Annotated[PseudonymReferralService, Depends(get_pseudonym_referral_service)],
5955
ctx: Annotated[AuthContext, Depends(require_scope(AuthorizationScope.DELETE))],
6056
source: Annotated[str, Depends(require_managing_source)],
6157
) -> Any:
6258
pseudonym_referral_service.delete_many(
6359
ctx.claims.ura_number,
64-
pseudonym=params.oprf_pseudonym(),
60+
pseudonym=pseudonym,
6561
source=source,
6662
)
6763

‎app/services/crypto_service_api_client.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from requests.exceptions import ConnectionError, HTTPError, Timeout
55

66
from app.config import ConfigCryptoServiceApi
7-
from app.models.pseudonym import PseudonymResponse
7+
from app.models.pseudonym import OprfPseudonym, PseudonymResponse
88
from app.services.http import HttpService
99

1010
logger = logging.getLogger(__name__)
@@ -20,14 +20,14 @@ def __init__(self, config: ConfigCryptoServiceApi) -> None:
2020
verify_ca=config.verify_ca,
2121
)
2222

23-
def exchange(self, jwe: str, blind_factor: str, label: str, mechanism: str) -> PseudonymResponse:
23+
def exchange(self, pseudonym: OprfPseudonym, label: str, mechanism: str) -> PseudonymResponse:
2424
try:
2525
response = self._http.do_request(
2626
method="POST",
2727
sub_route="process",
2828
data={
29-
"jwe": jwe,
30-
"blind_factor": blind_factor,
29+
"jwe": pseudonym.jwe,
30+
"blind_factor": pseudonym.blind_factor,
3131
"label": label,
3232
"mechanism": mechanism,
3333
},

‎app/services/fhir/bundle.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,15 @@
44
from uuid import UUID
55

66
import gfmodules.logging as gflog
7+
from pydantic import ValidationError
78

89
from app.errors.handlers import log_rejection
910
from app.errors.mapping import DEFAULT_SPEC, UNEXPECTED_ERROR_MESSAGE, spec_for
1011
from app.logging.events import Log
1112
from app.models.auth.context import AuthContext
1213
from app.models.auth.data import AuthorizationScope
1314
from app.models.fhir.bundle import Bundle, BundleEntry, EntryRequest, EntryRequestDto, EntryResponse
14-
from app.models.fhir.resources.localization_list.request import (
15-
LocalizationListParams,
16-
LocalizationListSearchParams,
17-
)
15+
from app.models.fhir.resources.localization_list.request import LocalizationListParams, LocalizationListSearchParams
1816
from app.models.fhir.resources.localization_list.resource import LocalizationList
1917
from app.models.ura import UraNumber
2018
from app.services.auth.auth_context import AuthContextService
@@ -184,9 +182,14 @@ def _parse_params(
184182
try:
185183
return model.model_validate(resolved_url.params)
186184
except ValueError as e:
185+
if isinstance(e, ValidationError):
186+
missing = all(err["type"] == "missing" for err in e.errors())
187+
else:
188+
missing = True
187189
return BundleEntry(
188190
response=EntryResponse.make_validation_response(
189-
f"Bundle.entry.{index}.request: invalid url parameter: {e}"
191+
f"Bundle.entry.{index}.request: invalid url parameter: {e}",
192+
code="required" if missing else "invalid",
190193
)
191194
)
192195

0 commit comments

Comments
 (0)