diff --git a/open_city_profile/tests/snapshots/snap_test_graphql_api_schema.py b/open_city_profile/tests/snapshots/snap_test_graphql_api_schema.py index 99c8402b..ac526795 100644 --- a/open_city_profile/tests/snapshots/snap_test_graphql_api_schema.py +++ b/open_city_profile/tests/snapshots/snap_test_graphql_api_schema.py @@ -122,6 +122,7 @@ phones(offset: Int, before: String, after: String, first: Int, last: Int): PhoneNodeConnection addresses(offset: Int, before: String, after: String, first: Int, last: Int): AddressNodeConnection contactMethod: ContactMethod + loginMethods: [String] sensitivedata: SensitiveDataNode serviceConnections(offset: Int, before: String, after: String, first: Int, last: Int): ServiceConnectionTypeConnection verifiedPersonalInformation: VerifiedPersonalInformationNode diff --git a/profiles/models.py b/profiles/models.py index ba0f55ac..32a67204 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -119,6 +119,7 @@ class Meta: "verified_personal_information", "language", "contact_method", + "login_methods", ] def resolve_profile(self): diff --git a/profiles/schema.py b/profiles/schema.py index d85dc49f..6fc99315 100644 --- a/profiles/schema.py +++ b/profiles/schema.py @@ -56,7 +56,7 @@ download_connected_service_data, ) from .enums import AddressType, EmailType, PhoneType -from .keycloak_integration import delete_profile_from_keycloak +from .keycloak_integration import delete_profile_from_keycloak, get_user_login_methods from .models import ( Address, ClaimToken, @@ -514,6 +514,10 @@ def validate_ssn(value, info, **input): class RestrictedProfileNode(DjangoObjectType): + """ + Profile node with a restricted set of data. This does not contain any sensitive data. + """ + class Meta: model = Profile fields = ("first_name", "last_name", "nickname", "language") @@ -575,6 +579,11 @@ class Meta: connection_class = ProfilesConnection filterset_class = ProfileFilter + login_methods = graphene.List( + graphene.String, + description="List of login methods that the profile has used to authenticate. " + "Only visible to the user themselves.", + ) sensitivedata = graphene.Field( SensitiveDataNode, description="Data that is consider to be sensitive e.g. social security number", @@ -589,6 +598,25 @@ class Meta: "privileges to access this information.", ) + def resolve_login_methods(self: Profile, info, **kwargs): + if info.context.user != self.user: + raise PermissionDenied( + "No permission to read login methods of another user." + ) + + amr = {info.context.user_auth.data.get("amr")} + + # For future software archeologists: + # This field was added to the API to support the front-end's need to know + # which login methods the user has used. It's only needed for profiles + # with helsinki-tunnus or Suomi.fi, so for other cases, save a couple + # API calls and return an empty list. There's no other reasoning for the + # logic here. + if amr.intersection({"helsinki_tunnus", "heltunnistussuomifi", "suomi_fi"}): + return get_user_login_methods(self.user.uuid) + + return [] + def resolve_service_connections(self: Profile, info, **kwargs): return self.effective_service_connections_qs() diff --git a/profiles/tests/test_gql_my_profile_query.py b/profiles/tests/test_gql_my_profile_query.py index cf1fcf50..3123fcc0 100644 --- a/profiles/tests/test_gql_my_profile_query.py +++ b/profiles/tests/test_gql_my_profile_query.py @@ -576,3 +576,61 @@ def test_my_profile_checks_allowed_data_fields_for_multiple_queries( assert executed["data"]["myProfile"]["lastName"] == profile.last_name assert executed["data"]["myProfile"]["sensitivedata"] is None assert executed["data"]["services"] is None + + +@pytest.mark.parametrize( + "amr_claim_value", ["suomi_fi", "helsinki_tunnus", "heltunnistussuomifi"] +) +def test_user_can_see_own_login_methods_with_correct_amr_claim( + user_gql_client, profile, group, service, monkeypatch, amr_claim_value +): + def mock_return(*_, **__): + return {"foo", "bar"} + + monkeypatch.setattr( + "profiles.keycloak_integration.get_user_identity_providers", mock_return + ) + + profile = ProfileFactory(user=user_gql_client.user) + ServiceConnectionFactory(profile=profile, service=service) + + query = """ + { + myProfile { + loginMethods + } + } + """ + executed = user_gql_client.execute( + query, auth_token_payload={"amr": amr_claim_value}, service=service + ) + assert "errors" not in executed + assert set(executed["data"]["myProfile"]["loginMethods"]) == {"foo", "bar"} + + +@pytest.mark.parametrize("amr_claim_value", [None, "helsinkiad"]) +def test_user_cannot_see_own_login_methods_with_other_amr_claims( + user_gql_client, profile, group, service, monkeypatch, amr_claim_value +): + def mock_return(*_, **__): + return {"this should not show up"} + + monkeypatch.setattr( + "profiles.keycloak_integration.get_user_identity_providers", mock_return + ) + + profile = ProfileFactory(user=user_gql_client.user) + ServiceConnectionFactory(profile=profile, service=service) + + query = """ + { + myProfile { + loginMethods + } + } + """ + executed = user_gql_client.execute( + query, auth_token_payload={"amr": amr_claim_value}, service=service + ) + assert "errors" not in executed + assert executed["data"]["myProfile"]["loginMethods"] == [] diff --git a/profiles/tests/test_gql_profiles_query.py b/profiles/tests/test_gql_profiles_query.py index 4ebc956c..8197435d 100644 --- a/profiles/tests/test_gql_profiles_query.py +++ b/profiles/tests/test_gql_profiles_query.py @@ -798,3 +798,29 @@ def test_not_specifying_requesters_service_results_in_permission_denied_error( """ executed = user_gql_client.execute(query) assert_match_error_code(executed, "PERMISSION_DENIED_ERROR") + + +def test_staff_user_can_not_query_login_methods_of_other_users( + user_gql_client, group, service +): + profile = ProfileFactory() + ServiceConnectionFactory(profile=profile, service=service) + user = user_gql_client.user + user.groups.add(group) + assign_perm("can_view_profiles", group, service) + + query = """ + { + profiles { + edges { + node { + loginMethods + } + } + } + } + """ + + executed = user_gql_client.execute(query, service=service) + assert "errors" in executed + assert_match_error_code(executed, "PERMISSION_DENIED_ERROR")