From fbe993d8c3878d70eb26e1ae76d932ec99764960 Mon Sep 17 00:00:00 2001 From: Matti Eiden Date: Thu, 14 Mar 2024 12:13:25 +0200 Subject: [PATCH] feat: prevent GDPR features with insufficient loa Originally profile would restrict verified information with insufficient loa. This is seemingly fine, but there is a risk of GDPR implementing service not checking loa and leaking sensitive information for an account that may contain information that should not be accessed with low loa. Therefore it was decided, that accounts which have verified personal information (ie. suomi.fi linked) may not use GDPR features unless they have at least substantial loa. Ref. HP-2269 --- open_city_profile/consts.py | 1 + open_city_profile/exceptions.py | 4 +++ open_city_profile/views.py | 3 +++ profiles/schema.py | 20 +++++++++++++- .../test_gql_delete_my_profile_mutation.py | 27 ++++++++++++++++++- .../test_gql_download_my_profile_query.py | 20 +++++++------- profiles/tests/test_audit_log_to_db.py | 4 ++- profiles/tests/test_audit_log_to_logger.py | 4 ++- profiles/utils.py | 12 +++++++++ 9 files changed, 82 insertions(+), 13 deletions(-) diff --git a/open_city_profile/consts.py b/open_city_profile/consts.py index 616a1165..ff535cf3 100644 --- a/open_city_profile/consts.py +++ b/open_city_profile/consts.py @@ -22,6 +22,7 @@ SERVICE_NOT_IDENTIFIED_ERROR = "SERVICE_NOT_IDENTIFIED_ERROR" PROFILE_ALREADY_EXISTS_FOR_USER_ERROR = "PROFILE_ALREADY_EXISTS_FOR_USER_ERROR" PROFILE_MUST_HAVE_PRIMARY_EMAIL = "PROFILE_MUST_HAVE_PRIMARY_EMAIL" +INSUFFICIENT_LOA_ERROR = "INSUFFICIENT_LOA_ERROR" # Service specific GDPR errors SERVICE_GDPR_API_REQUEST_ERROR = "SERVICE_GDPR_API_REQUEST_ERROR" diff --git a/open_city_profile/exceptions.py b/open_city_profile/exceptions.py index bf9a8083..766209d9 100644 --- a/open_city_profile/exceptions.py +++ b/open_city_profile/exceptions.py @@ -69,3 +69,7 @@ class TokenExpiredError(ProfileGraphQLError): class TokenExchangeError(Exception): """OAuth/OIDC token exchange related exception.""" + + +class InsufficientLoaError(ProfileGraphQLError): + """The requester has insufficient level of authentication to retrieve this data""" diff --git a/open_city_profile/views.py b/open_city_profile/views.py index cf3d3102..d373637e 100644 --- a/open_city_profile/views.py +++ b/open_city_profile/views.py @@ -13,6 +13,7 @@ CONNECTED_SERVICE_DELETION_NOT_ALLOWED_ERROR, DATA_CONFLICT_ERROR, GENERAL_ERROR, + INSUFFICIENT_LOA_ERROR, INVALID_EMAIL_FORMAT_ERROR, JWT_AUTHENTICATION_ERROR, MISSING_GDPR_API_TOKEN_ERROR, @@ -33,6 +34,7 @@ ConnectedServiceDeletionFailedError, ConnectedServiceDeletionNotAllowedError, DataConflictError, + InsufficientLoaError, InvalidEmailFormatError, MissingGDPRApiTokenError, ProfileAlreadyExistsForUserError, @@ -71,6 +73,7 @@ ServiceAlreadyExistsError: SERVICE_CONNECTION_ALREADY_EXISTS_ERROR, ServiceConnectionDoesNotExist: SERVICE_CONNECTION_DOES_NOT_EXIST_ERROR, ServiceNotIdentifiedError: SERVICE_NOT_IDENTIFIED_ERROR, + InsufficientLoaError: INSUFFICIENT_LOA_ERROR, } sentry_ignored_errors = ( diff --git a/profiles/schema.py b/profiles/schema.py index 1cc6b041..167d5778 100644 --- a/profiles/schema.py +++ b/profiles/schema.py @@ -37,6 +37,7 @@ from open_city_profile.exceptions import ( ConnectedServiceDeletionFailedError, ConnectedServiceDeletionNotAllowedError, + InsufficientLoaError, InvalidEmailFormatError, ProfileAlreadyExistsForUserError, ProfileDoesNotExistError, @@ -70,7 +71,10 @@ VerifiedPersonalInformationPermanentForeignAddress, VerifiedPersonalInformationTemporaryAddress, ) -from .utils import requester_can_view_verified_personal_information +from .utils import ( + requester_can_view_verified_personal_information, + requester_has_sufficient_loa_to_perform_gdpr_request, +) User = get_user_model() @@ -1487,6 +1491,10 @@ def mutate_and_get_payload(cls, root, info, **input): _("You do not have permission to perform this action.") ) + if not requester_has_sufficient_loa_to_perform_gdpr_request(info.context): + raise InsufficientLoaError( + _("You have insufficient level of authentication to perform this action.") + ) dry_run = input.get("dry_run", False) results = delete_connected_service_data( @@ -1551,6 +1559,11 @@ def mutate(parent, info, input): _("You do not have permission to perform this action.") ) + if not requester_has_sufficient_loa_to_perform_gdpr_request(info.context): + raise InsufficientLoaError( + _("You have insufficient level of authentication to perform this action.") + ) + service_connections = profile.effective_service_connections_qs().filter( service__name=input["service_name"] ) @@ -1724,6 +1737,11 @@ def resolve_download_my_profile(self, info, **kwargs): _("You do not have permission to perform this action.") ) + if not requester_has_sufficient_loa_to_perform_gdpr_request(info.context): + raise InsufficientLoaError( + _("You have insufficient level of authentication to perform this action.") + ) + external_data = download_connected_service_data( profile, kwargs["authorization_code"], diff --git a/profiles/tests/gdpr/test_gql_delete_my_profile_mutation.py b/profiles/tests/gdpr/test_gql_delete_my_profile_mutation.py index cf10dbba..ea693f60 100644 --- a/profiles/tests/gdpr/test_gql_delete_my_profile_mutation.py +++ b/profiles/tests/gdpr/test_gql_delete_my_profile_mutation.py @@ -11,7 +11,11 @@ from open_city_profile.oidc import TunnistamoTokenExchange from open_city_profile.tests.asserts import assert_match_error_code from profiles.models import Profile -from profiles.tests.factories import ProfileFactory, ProfileWithPrimaryEmailFactory +from profiles.tests.factories import ( + ProfileFactory, + ProfileWithPrimaryEmailFactory, + VerifiedPersonalInformationFactory, +) from services.models import ServiceConnection from services.tests.factories import ServiceConnectionFactory from users.models import User @@ -546,3 +550,24 @@ def test_api_tokens_missing(user_gql_client, service_1, mocker): executed = user_gql_client.execute(DELETE_MY_PROFILE_MUTATION) assert_match_error_code(executed, MISSING_GDPR_API_TOKEN_ERROR) + + +@pytest.mark.parametrize( + "loa,errors", + [ + ("low", True), + ("substantial", False), + ("high", False), + ], +) +def test_delete_with_verified_personal_information( + user_gql_client, gdpr_api_tokens, loa, errors +): + """Deletion is allowed when GDPR URL is set, and service returns a successful status.""" + profile = ProfileFactory(user=user_gql_client.user) + VerifiedPersonalInformationFactory(profile=profile) + + executed = user_gql_client.execute( + DELETE_MY_PROFILE_MUTATION, auth_token_payload={"loa": loa} + ) + assert bool(executed.get("errors")) == errors diff --git a/profiles/tests/gdpr/test_gql_download_my_profile_query.py b/profiles/tests/gdpr/test_gql_download_my_profile_query.py index f527c8a5..734c8b25 100644 --- a/profiles/tests/gdpr/test_gql_download_my_profile_query.py +++ b/profiles/tests/gdpr/test_gql_download_my_profile_query.py @@ -2,7 +2,6 @@ from string import Template import pytest -from django.utils.translation import gettext as _ from open_city_profile.consts import MISSING_GDPR_API_TOKEN_ERROR from open_city_profile.oidc import TunnistamoTokenExchange @@ -128,6 +127,7 @@ def download_verified_personal_information_with_loa(loa, user_gql_client, servic ) full_dump = json.loads(executed["data"]["downloadMyProfile"]) + profile_dump = next( child for child in full_dump["children"] if child["key"] == "PROFILE" ) @@ -154,16 +154,18 @@ def test_verified_personal_information_is_included_in_the_downloaded_profile_whe @pytest.mark.parametrize("loa", [None, "foo", "low"]) def test_verified_personal_information_is_replaced_with_an_error_when_loa_is_not_high_enough( - loa, user_gql_client, profile_service + loa, user_gql_client, profile_service, service ): - vpi_dump = download_verified_personal_information_with_loa( - loa, user_gql_client, profile_service - ) - - assert vpi_dump == { - "key": "VERIFIEDPERSONALINFORMATION", - "error": _("No permission to read verified personal information."), + VerifiedPersonalInformationFactory(profile__user=user_gql_client.user) + token_payload = { + "loa": loa, } + executed = user_gql_client.execute( + DOWNLOAD_MY_PROFILE_MUTATION, service=service, auth_token_payload=token_payload + ) + assert executed["data"]["downloadMyProfile"] is None + assert len(executed["errors"]) == 1 + assert executed["errors"][0]["extensions"]["code"] == "PERMISSION_DENIED_ERROR" def test_downloading_non_existent_profile_doesnt_return_errors(user_gql_client): diff --git a/profiles/tests/test_audit_log_to_db.py b/profiles/tests/test_audit_log_to_db.py index f8779820..19d467f1 100644 --- a/profiles/tests/test_audit_log_to_db.py +++ b/profiles/tests/test_audit_log_to_db.py @@ -414,7 +414,9 @@ def test_audit_log_delete(live_server, profile_with_related): } } """ - do_graphql_call_as_user(live_server, user, query=query) + do_graphql_call_as_user( + live_server, user, query=query, extra_claims={"loa": "substantial"} + ) log_entries = list(LogEntry.objects.all()) # Audit logging the Profile DELETE with a related object causes some READs diff --git a/profiles/tests/test_audit_log_to_logger.py b/profiles/tests/test_audit_log_to_logger.py index 1e1610d6..794ba02b 100644 --- a/profiles/tests/test_audit_log_to_logger.py +++ b/profiles/tests/test_audit_log_to_logger.py @@ -442,7 +442,9 @@ def test_audit_log_delete(live_server, profile_with_related, cap_audit_log): } } """ - do_graphql_call_as_user(live_server, user, query=query) + do_graphql_call_as_user( + live_server, user, query=query, extra_claims={"loa": "substantial"} + ) audit_logs = cap_audit_log.get_logs() # Audit logging the Profile DELETE with a related object causes some READs diff --git a/profiles/utils.py b/profiles/utils.py index 8f06eece..cad1192c 100644 --- a/profiles/utils.py +++ b/profiles/utils.py @@ -29,3 +29,15 @@ def requester_can_view_verified_personal_information(request): or request.user_auth.data.get("amr") in settings.VERIFIED_PERSONAL_INFORMATION_ACCESS_AMR_LIST ) + + +def requester_has_sufficient_loa_to_perform_gdpr_request(request): + user = request.user + profile = user.profile + if not profile: + return False + loa = request.user_auth.data.get("loa") + return not hasattr(profile, "verified_personal_information") or loa in [ + "substantial", + "high", + ]