From d529b4de5754a35e971a030ddc6c6bbff45543b8 Mon Sep 17 00:00:00 2001 From: Marco Cucchi Date: Thu, 28 Aug 2025 15:13:26 +0200 Subject: [PATCH 1/6] introuce option to allow unauthenticated users for certain endpoints --- cads_processing_api_service/auth.py | 95 +++++++++++++----------- cads_processing_api_service/endpoints.py | 20 +++-- cads_processing_api_service/models.py | 4 +- 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/cads_processing_api_service/auth.py b/cads_processing_api_service/auth.py index 38d9ca5..6c5b220 100644 --- a/cads_processing_api_service/auth.py +++ b/cads_processing_api_service/auth.py @@ -34,7 +34,9 @@ REQUEST_ORIGIN = {"PRIVATE-TOKEN": "api", "Authorization": "ui"} -def get_auth_header(pat: str | None = None, jwt: str | None = None) -> tuple[str, str]: +def get_auth_header( + pat: str | None = None, jwt: str | None = None +) -> tuple[str | None, str | None]: """Infer authentication header based on authentication tokens. Parameters @@ -46,35 +48,53 @@ def get_auth_header(pat: str | None = None, jwt: str | None = None) -> tuple[str Returns ------- - tuple[str, str] + tuple[str | None, str | None] Authentication header. - - Raises - ------ - exceptions.PermissionDenied - Raised if none of the expected authentication headers is provided. """ - if not pat and not jwt: - raise exceptions.PermissionDenied( - status_code=fastapi.status.HTTP_401_UNAUTHORIZED, - detail="authentication required", - ) if pat: auth_header = ("PRIVATE-TOKEN", pat) elif jwt: auth_header = ("Authorization", jwt) - + else: + auth_header = (None, None) return auth_header +def authenticate_user( + auth_header: tuple[str, str], portal_header: str | None = None +) -> dict[str, str]: + verification_endpoint = VERIFICATION_ENDPOINT[auth_header[0]] + request_url = urllib.parse.urljoin(SETTINGS.profiles_api_url, verification_endpoint) + response = requests.post( + request_url, + headers={ + auth_header[0]: auth_header[1], + SETTINGS.portal_header_name: portal_header, + }, + ) + response_content: dict[str, Any] = response.json() + if response.status_code in ( + fastapi.status.HTTP_401_UNAUTHORIZED, + fastapi.status.HTTP_403_FORBIDDEN, + ): + raise exceptions.PermissionDenied( + status_code=response.status_code, + title=response_content["title"], + detail=response_content.get("detail", "operation not allowed"), + ) + response.raise_for_status() + user_info = response_content + return user_info + + @cachetools.cached( cache=cachetools.TTLCache( maxsize=SETTINGS.cache_users_maxsize, ttl=SETTINGS.cache_users_ttl, ), ) -def authenticate_user( - auth_header: tuple[str, str], portal_header: str | None = None +def get_user_info( + auth_header: tuple[str | None, str | None], portal_header: str | None = None ) -> tuple[str, str | None, str | None]: """Verify user authentication. @@ -83,7 +103,7 @@ def authenticate_user( Parameters ---------- - auth_header : tuple[str, str] + auth_header : tuple[str | None, str | None] Authentication header. Returns @@ -97,30 +117,13 @@ def authenticate_user( Raised if the provided authentication header doesn't correspond to a registered/authorized user. """ - verification_endpoint = VERIFICATION_ENDPOINT[auth_header[0]] - request_url = urllib.parse.urljoin(SETTINGS.profiles_api_url, verification_endpoint) - response = requests.post( - request_url, - headers={ - auth_header[0]: auth_header[1], - SETTINGS.portal_header_name: portal_header, - }, - ) - response_content: dict[str, Any] = response.json() - if response.status_code in ( - fastapi.status.HTTP_401_UNAUTHORIZED, - fastapi.status.HTTP_403_FORBIDDEN, - ): - raise exceptions.PermissionDenied( - status_code=response.status_code, - title=response_content["title"], - detail=response_content.get("detail", "operation not allowed"), - ) - response.raise_for_status() - user: dict[str, str] = response_content - user_uid: str = user["sub"] - user_role: str | None = user.get("role", None) - email: str | None = user.get("email", None) + if auth_header[0] is not None: + user_info = authenticate_user(auth_header, portal_header) + else: + user_info = {"sub": "unauthenticated"} + user_uid: str = user_info["sub"] + user_role: str | None = user_info.get("role", None) + email: str | None = user_info.get("email", None) return user_uid, user_role, email @@ -137,6 +140,7 @@ def get_auth_info( portal_header: str | None = fastapi.Header( None, alias=SETTINGS.portal_header_name, include_in_schema=False ), + allow_unauthenticated: bool = False, ) -> models.AuthInfo | None: """Get authentication information from the incoming HTTP request. @@ -159,9 +163,16 @@ def get_auth_info( exceptions.PermissionDenied Raised if none of the expected authentication headers is provided. """ + if pat is None and jwt is None and not allow_unauthenticated: + raise exceptions.PermissionDenied( + status_code=fastapi.status.HTTP_401_UNAUTHORIZED, + detail="authentication required", + ) auth_header = get_auth_header(pat, jwt) - user_uid, user_role, email = authenticate_user(auth_header, portal_header) - request_origin = REQUEST_ORIGIN[auth_header[0]] + user_uid, user_role, email = get_user_info(auth_header, portal_header) + request_origin = ( + REQUEST_ORIGIN[auth_header[0]] if auth_header[0] is not None else None + ) portals = utils.get_portals(portal_header) auth_info = models.AuthInfo( user_uid=user_uid, diff --git a/cads_processing_api_service/endpoints.py b/cads_processing_api_service/endpoints.py index dba8b26..cc6d571 100644 --- a/cads_processing_api_service/endpoints.py +++ b/cads_processing_api_service/endpoints.py @@ -1,5 +1,6 @@ """Additional endpoints for the CADS Processing API Service.""" +import functools from typing import Any import cads_adaptors @@ -25,13 +26,17 @@ SETTINGS = config.settings +logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + @exceptions.exception_logger def apply_constraints( process_id: str = fastapi.Path(..., description="Process identifier."), execution_content: models.Execute = fastapi.Body(...), - portals: tuple[str] | None = fastapi.Depends( - exceptions.exception_logger(utils.get_portals) + auth_info: models.AuthInfo = fastapi.Depends( + exceptions.exception_logger( + functools.partial(auth.get_auth_info, allow_unauthenticated=True) + ) ), ) -> dict[str, Any]: request = execution_content.model_dump() @@ -44,7 +49,7 @@ def apply_constraints( resource_id=process_id, table=table, session=catalogue_session, - portals=portals, + portals=auth_info.portals, ) adaptor: cads_adaptors.AbstractAdaptor = adaptors.instantiate_adaptor(dataset) try: @@ -56,7 +61,6 @@ def apply_constraints( cads_adaptors.exceptions.InvalidRequest, ) as exc: raise exceptions.InvalidParameter(detail=str(exc)) from exc - return constraints @@ -68,8 +72,10 @@ def estimate_cost( ), mandatory_inputs: bool = fastapi.Query(False, include_in_schema=False), execution_content: models.Execute = fastapi.Body(...), - portals: tuple[str] | None = fastapi.Depends( - exceptions.exception_logger(utils.get_portals) + auth_info: models.AuthInfo = fastapi.Depends( + exceptions.exception_logger( + functools.partial(auth.get_auth_info, allow_unauthenticated=True) + ) ), ) -> models.RequestCost: """ @@ -97,7 +103,7 @@ def estimate_cost( resource_id=process_id, table=table, session=catalogue_session, - portals=portals, + portals=auth_info.portals, ) adaptor_properties = adaptors.get_adaptor_properties(dataset) costing_info = costing.compute_costing( diff --git a/cads_processing_api_service/models.py b/cads_processing_api_service/models.py index 76dd8c5..26e986b 100644 --- a/cads_processing_api_service/models.py +++ b/cads_processing_api_service/models.py @@ -26,8 +26,8 @@ class AuthInfo(pydantic.BaseModel): user_uid: str user_role: str | None = None email: str | None = None - request_origin: str - auth_header: tuple[str, str] + request_origin: str | None = None + auth_header: tuple[str | None, str | None] = (None, None) portals: tuple[str, ...] | None = None From 4028eee392c4cc642c18bb8ae65f338e98a4173f Mon Sep 17 00:00:00 2001 From: Marco Cucchi Date: Thu, 28 Aug 2025 15:13:49 +0200 Subject: [PATCH 2/6] update tests --- tests/test_30_auth.py | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_30_auth.py b/tests/test_30_auth.py index f4556d8..276710e 100644 --- a/tests/test_30_auth.py +++ b/tests/test_30_auth.py @@ -20,6 +20,69 @@ from cads_processing_api_service import auth, exceptions, models +@pytest.mark.parametrize( + "pat, jwt, expected", + [ + ("test_pat", None, ("PRIVATE-TOKEN", "test_pat")), + (None, "test_jwt", ("Authorization", "test_jwt")), + (None, None, (None, None)), + ], + ids=["pat", "jwt", "neither"], +) +def test_get_auth_header(pat, jwt, expected) -> None: + auth_header = auth.get_auth_header(pat, jwt) + assert auth_header == expected + + +def test_get_user_info_authenticated(mocker) -> None: + auth_header = ("PRIVATE-TOKEN", "test_pat") + portal_header = "test_portal" + mocker.patch( + "cads_processing_api_service.auth.authenticate_user", + return_value={ + "sub": "test_user", + "role": "user", + "email": "test_user@example.com", + }, + ) + expected = ("test_user", "user", "test_user@example.com") + user_info = auth.get_user_info(auth_header, portal_header) + assert user_info == expected + + +def test_get_user_info_unauthenticated() -> None: + auth_header = (None, None) + portal_header = "test_portal" + user_info = auth.get_user_info(auth_header, portal_header) + assert user_info == ("unauthenticated", None, None) + + +def test_get_auth_info_not_allowed_unauthenticated() -> None: + pat = None + jwt = None + portal_header = "test_portal" + allow_unauthenticated = False + with pytest.raises(exceptions.PermissionDenied): + auth.get_auth_info(pat, jwt, portal_header, allow_unauthenticated) + + +def test_get_auth_info_allowed_unauthenticated() -> None: + pat = None + jwt = None + portal_header = "test_portal" + allow_unauthenticated = True + expected = models.AuthInfo( + user_uid="unauthenticated", + user_role=None, + email=None, + request_origin=None, + auth_header=(None, None), + portals=("test_portal",), + ) + auth_info = auth.get_auth_info(pat, jwt, portal_header, allow_unauthenticated) + assert auth_info == expected + + def test_format_missing_licences_message(mocker) -> None: request_url = "http://base_url/api/v1/processes/process_id/execution" process_id = "test_process_id" From 38de282738c8dbb80b19dc0a6e44b5c6cef158f1 Mon Sep 17 00:00:00 2001 From: Marco Cucchi Date: Thu, 28 Aug 2025 16:16:17 +0200 Subject: [PATCH 3/6] fix types --- cads_processing_api_service/auth.py | 4 ++-- cads_processing_api_service/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cads_processing_api_service/auth.py b/cads_processing_api_service/auth.py index 6c5b220..a78895e 100644 --- a/cads_processing_api_service/auth.py +++ b/cads_processing_api_service/auth.py @@ -141,7 +141,7 @@ def get_auth_info( None, alias=SETTINGS.portal_header_name, include_in_schema=False ), allow_unauthenticated: bool = False, -) -> models.AuthInfo | None: +) -> models.AuthInfo: """Get authentication information from the incoming HTTP request. Parameters @@ -155,7 +155,7 @@ def get_auth_info( Returns ------- - dict[str, str] | None + models.AuthInfo User identifier and role. Raises diff --git a/cads_processing_api_service/utils.py b/cads_processing_api_service/utils.py index 233cf9e..eec2c3d 100644 --- a/cads_processing_api_service/utils.py +++ b/cads_processing_api_service/utils.py @@ -69,7 +69,7 @@ def lookup_resource_by_id( table: type[cads_catalogue.database.Resource], session: sqlalchemy.orm.Session, load_messages: bool = False, - portals: tuple[str] | None = None, + portals: tuple[str, ...] | None = None, ) -> cads_catalogue.database.Resource: """Look for the resource identified by `id` into the Catalogue database. @@ -83,7 +83,7 @@ def lookup_resource_by_id( Catalogue database session. load_messages : bool, optional If True, load resource messages, by default False. - portals: tuple[str] | None, optional + portals: tuple[str, ...] | None, optional Portals to filter resources by, by default None. Returns From 819427dbbf62fc157a13dfeff4210bf1e0584468 Mon Sep 17 00:00:00 2001 From: Marco Cucchi Date: Thu, 28 Aug 2025 16:25:30 +0200 Subject: [PATCH 4/6] fix types --- cads_processing_api_service/auth.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cads_processing_api_service/auth.py b/cads_processing_api_service/auth.py index a78895e..f0d2b12 100644 --- a/cads_processing_api_service/auth.py +++ b/cads_processing_api_service/auth.py @@ -51,17 +51,16 @@ def get_auth_header( tuple[str | None, str | None] Authentication header. """ + auth_header: tuple[str | None, str | None] = (None, None) if pat: auth_header = ("PRIVATE-TOKEN", pat) elif jwt: auth_header = ("Authorization", jwt) - else: - auth_header = (None, None) return auth_header def authenticate_user( - auth_header: tuple[str, str], portal_header: str | None = None + auth_header: tuple[str, str | None], portal_header: str | None = None ) -> dict[str, str]: verification_endpoint = VERIFICATION_ENDPOINT[auth_header[0]] request_url = urllib.parse.urljoin(SETTINGS.profiles_api_url, verification_endpoint) @@ -118,7 +117,7 @@ def get_user_info( registered/authorized user. """ if auth_header[0] is not None: - user_info = authenticate_user(auth_header, portal_header) + user_info = authenticate_user(auth_header, portal_header) # type: ignore else: user_info = {"sub": "unauthenticated"} user_uid: str = user_info["sub"] From 656b45d8919b5924110f8895718f15f3310f1dd6 Mon Sep 17 00:00:00 2001 From: Marco Cucchi Date: Thu, 28 Aug 2025 17:57:49 +0200 Subject: [PATCH 5/6] use also Referer header to set request origin --- cads_processing_api_service/auth.py | 62 ++++++++++++++++++++++----- cads_processing_api_service/models.py | 4 +- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/cads_processing_api_service/auth.py b/cads_processing_api_service/auth.py index f0d2b12..a0a0de2 100644 --- a/cads_processing_api_service/auth.py +++ b/cads_processing_api_service/auth.py @@ -36,7 +36,7 @@ def get_auth_header( pat: str | None = None, jwt: str | None = None -) -> tuple[str | None, str | None]: +) -> tuple[str, str] | None: """Infer authentication header based on authentication tokens. Parameters @@ -48,10 +48,10 @@ def get_auth_header( Returns ------- - tuple[str | None, str | None] + tuple[str, str] | None Authentication header. """ - auth_header: tuple[str | None, str | None] = (None, None) + auth_header: tuple[str, str] | None = None if pat: auth_header = ("PRIVATE-TOKEN", pat) elif jwt: @@ -60,7 +60,7 @@ def get_auth_header( def authenticate_user( - auth_header: tuple[str, str | None], portal_header: str | None = None + auth_header: tuple[str, str], portal_header: str | None = None ) -> dict[str, str]: verification_endpoint = VERIFICATION_ENDPOINT[auth_header[0]] request_url = urllib.parse.urljoin(SETTINGS.profiles_api_url, verification_endpoint) @@ -93,7 +93,8 @@ def authenticate_user( ), ) def get_user_info( - auth_header: tuple[str | None, str | None], portal_header: str | None = None + auth_header: tuple[str, str] | None, + portal_header: str | None = None, ) -> tuple[str, str | None, str | None]: """Verify user authentication. @@ -102,8 +103,10 @@ def get_user_info( Parameters ---------- - auth_header : tuple[str | None, str | None] + auth_header : tuple[str, str] | None Authentication header. + portal_header : str | None, optional + Portal header value. Returns ------- @@ -116,8 +119,8 @@ def get_user_info( Raised if the provided authentication header doesn't correspond to a registered/authorized user. """ - if auth_header[0] is not None: - user_info = authenticate_user(auth_header, portal_header) # type: ignore + if auth_header is not None: + user_info = authenticate_user(auth_header, portal_header) else: user_info = {"sub": "unauthenticated"} user_uid: str = user_info["sub"] @@ -126,6 +129,40 @@ def get_user_info( return user_uid, user_role, email +def get_request_origin( + auth_header: tuple[str, str] | None, + referer: str | None = None, + auth_header_to_request_origin: dict[str, str] = REQUEST_ORIGIN, +) -> str: + """Get the request origin based on the authentication header. + + Parameters + ---------- + auth_header : tuple[str, str] | None + Authentication header. + referer : str | None, optional + Referer header value. + auth_header_to_request_origin : dict[str, str], optional + Mapping of authentication headers to request origins. + + Returns + ------- + str + Request origin. + """ + request_origin = ( + auth_header_to_request_origin[auth_header[0]] + if auth_header is not None + else None + ) + if request_origin is None: + if referer is not None: + request_origin = "ui" + else: + request_origin = "api" + return request_origin + + def get_auth_info( pat: str | None = fastapi.Header( None, description="API key.", alias="PRIVATE-TOKEN" @@ -136,6 +173,9 @@ def get_auth_info( alias="Authorization", include_in_schema=False, ), + referer: str | None = fastapi.Header( + None, description="Referer header", alias="Referer", include_in_schema=False + ), portal_header: str | None = fastapi.Header( None, alias=SETTINGS.portal_header_name, include_in_schema=False ), @@ -149,6 +189,8 @@ def get_auth_info( API key jwt : str | None, optional JSON Web Token + referer : str | None, optional + Referer header portal_header : str | None, optional Portal header @@ -169,9 +211,7 @@ def get_auth_info( ) auth_header = get_auth_header(pat, jwt) user_uid, user_role, email = get_user_info(auth_header, portal_header) - request_origin = ( - REQUEST_ORIGIN[auth_header[0]] if auth_header[0] is not None else None - ) + request_origin = get_request_origin(auth_header, referer) portals = utils.get_portals(portal_header) auth_info = models.AuthInfo( user_uid=user_uid, diff --git a/cads_processing_api_service/models.py b/cads_processing_api_service/models.py index 26e986b..522ca5d 100644 --- a/cads_processing_api_service/models.py +++ b/cads_processing_api_service/models.py @@ -24,10 +24,10 @@ class AuthInfo(pydantic.BaseModel): user_uid: str + request_origin: str user_role: str | None = None email: str | None = None - request_origin: str | None = None - auth_header: tuple[str | None, str | None] = (None, None) + auth_header: tuple[str, str] | None = None portals: tuple[str, ...] | None = None From ddae6b127fd1eac846ec89942e55b5e1588ab1ff Mon Sep 17 00:00:00 2001 From: Marco Cucchi Date: Thu, 28 Aug 2025 17:58:05 +0200 Subject: [PATCH 6/6] update tests --- tests/test_30_auth.py | 48 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/tests/test_30_auth.py b/tests/test_30_auth.py index 276710e..b46dc77 100644 --- a/tests/test_30_auth.py +++ b/tests/test_30_auth.py @@ -25,7 +25,7 @@ [ ("test_pat", None, ("PRIVATE-TOKEN", "test_pat")), (None, "test_jwt", ("Authorization", "test_jwt")), - (None, None, (None, None)), + (None, None, None), ], ids=["pat", "jwt", "neither"], ) @@ -51,35 +51,71 @@ def test_get_user_info_authenticated(mocker) -> None: def test_get_user_info_unauthenticated() -> None: - auth_header = (None, None) + auth_header = None portal_header = "test_portal" user_info = auth.get_user_info(auth_header, portal_header) assert user_info == ("unauthenticated", None, None) +def test_get_request_origin_from_auth_header() -> None: + auth_header = ("Auth-Header-1", "test_token") + referer = None + auth_header_to_request_origin = { + "Auth-Header-1": "origin_1", + "Auth-Header-2": "origin_2", + } + request_origin = auth.get_request_origin( + auth_header, referer, auth_header_to_request_origin + ) + assert request_origin == "origin_1" + + +def test_get_request_origin_from_referer_defined() -> None: + auth_header = None + referer = "http://example.com/some/path" + request_origin = auth.get_request_origin(auth_header, referer) + assert request_origin == "ui" + + +def test_get_request_origin_from_referer_not_defined() -> None: + auth_header = None + referer = None + request_origin = auth.get_request_origin(auth_header, referer) + assert request_origin == "api" + + def test_get_auth_info_not_allowed_unauthenticated() -> None: pat = None jwt = None portal_header = "test_portal" allow_unauthenticated = False with pytest.raises(exceptions.PermissionDenied): - auth.get_auth_info(pat, jwt, portal_header, allow_unauthenticated) + auth.get_auth_info( + pat, jwt, portal_header, allow_unauthenticated=allow_unauthenticated + ) def test_get_auth_info_allowed_unauthenticated() -> None: pat = None jwt = None + referer = None portal_header = "test_portal" allow_unauthenticated = True expected = models.AuthInfo( user_uid="unauthenticated", user_role=None, email=None, - request_origin=None, - auth_header=(None, None), + request_origin="api", + auth_header=None, portals=("test_portal",), ) - auth_info = auth.get_auth_info(pat, jwt, portal_header, allow_unauthenticated) + auth_info = auth.get_auth_info( + pat, + jwt, + referer, + portal_header, + allow_unauthenticated, + ) assert auth_info == expected