Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HP-2490: Add login_methods field in ProfileNode #507

Merged
merged 10 commits into from
Jul 24, 2024
18 changes: 16 additions & 2 deletions open_city_profile/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@


def _use_context_tests(*test_funcs):
"""
Decorator for running context tests before the decorated function.

E.g. to create a decorator that checks that the user is authenticated::

def _require_authenticated(context):
# Check that the user is authenticated
...

@_use_context_tests(_require_authenticated)
def login_required():
pass
"""

def decorator(decorator_function):
@wraps(decorator_function)
def wrapper(function):
Expand Down Expand Up @@ -61,12 +75,12 @@ def permission_checker(context):


@_use_context_tests(_require_authenticated)
def login_required():
def login_required(*_, **__):
"""Decorator for checking that the user is logged in"""


@_use_context_tests(_require_authenticated, _require_service)
def login_and_service_required():
def login_and_service_required(*_, **__):
"""Decorator for checking that the user is logged in and service is known"""


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion profiles/keycloak_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
)
from utils import keycloak

_keycloak_admin_client = None
_keycloak_admin_client: keycloak.KeycloakAdminClient | None = None


def _setup_keycloak_client():
Expand Down Expand Up @@ -103,3 +103,29 @@ def send_profile_changes_to_keycloak(instance):
_keycloak_admin_client.send_verify_email(user_id)
except Exception:
pass


def get_user_identity_providers(user_id) -> set[str]:
if not _keycloak_admin_client:
return set()

try:
user_data = _keycloak_admin_client.get_user_federated_identities(user_id)
return {ip["identityProvider"] for ip in user_data}
except keycloak.UserNotFoundError:
return set()


def get_user_credential_types(user_id) -> set[str]:
if not _keycloak_admin_client:
return set()

try:
user_data = _keycloak_admin_client.get_user_credentials(user_id)
return {cred["type"] for cred in user_data}
except keycloak.UserNotFoundError:
return set()


def get_user_login_methods(user_id) -> set[str]:
return get_user_identity_providers(user_id) | get_user_credential_types(user_id)
1 change: 1 addition & 0 deletions profiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class Meta:
"verified_personal_information",
"language",
"contact_method",
"login_methods",
]

def resolve_profile(self):
Expand Down
30 changes: 29 additions & 1 deletion profiles/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.",
)
danipran marked this conversation as resolved.
Show resolved Hide resolved
sensitivedata = graphene.Field(
SensitiveDataNode,
description="Data that is consider to be sensitive e.g. social security number",
Expand All @@ -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")}
danipran marked this conversation as resolved.
Show resolved Hide resolved

# 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)
danipran marked this conversation as resolved.
Show resolved Hide resolved

return []

def resolve_service_connections(self: Profile, info, **kwargs):
return self.effective_service_connections_qs()

Expand Down
58 changes: 58 additions & 0 deletions profiles/tests/test_gql_my_profile_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == []
26 changes: 26 additions & 0 deletions profiles/tests/test_gql_profiles_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
120 changes: 120 additions & 0 deletions profiles/tests/test_keycloak_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from unittest.mock import MagicMock

import pytest

from profiles.keycloak_integration import (
get_user_credential_types,
get_user_identity_providers,
get_user_login_methods,
)
from utils.keycloak import UserNotFoundError


@pytest.fixture
def mock_keycloak_admin_client(monkeypatch):
mock = MagicMock()
monkeypatch.setattr("profiles.keycloak_integration._keycloak_admin_client", mock)
return mock


# Tests for get_user_identity_providers
def test_get_user_identity_providers_no_client(monkeypatch):
"""Test the function when _keycloak_admin_client is None."""
monkeypatch.setattr("profiles.keycloak_integration._keycloak_admin_client", None)

assert get_user_identity_providers("dummy_user_id") == set()


@pytest.mark.parametrize(
"mock_return_value, expected_result",
[
([], set()),
([{"identityProvider": "helsinkiad"}], {"helsinkiad"}),
(
[{"identityProvider": "helsinkiad"}, {"identityProvider": "suomi_fi"}],
{"helsinkiad", "suomi_fi"},
),
],
)
def test_get_user_identity_providers_with_data(
mock_keycloak_admin_client, mock_return_value, expected_result
):
mock_keycloak_admin_client.get_user_federated_identities.return_value = (
mock_return_value
)

assert get_user_identity_providers("dummy_user_id") == expected_result


def test_get_user_identity_providers_user_not_found(mock_keycloak_admin_client):
"""Test the function when the user is not found."""
mock_keycloak_admin_client.get_user_federated_identities.side_effect = (
UserNotFoundError
)

assert get_user_identity_providers("dummy_user_id") == set()


def test_get_user_federated_identities_no_identities(mock_keycloak_admin_client):
"""Test the function when the user has no federated identities."""
mock_keycloak_admin_client.get_user_federated_identities.return_value = set()

assert get_user_identity_providers("dummy_user_id") == set()


def test_get_user_identity_providers_exception(mock_keycloak_admin_client):
"""Test the function when an exception is raised."""
mock_keycloak_admin_client.get_user_federated_identities.side_effect = Exception

with pytest.raises(Exception):
get_user_identity_providers("dummy_user_id")


# Tests for get_user_credential_types
def test_get_user_credential_types_no_client(monkeypatch):
monkeypatch.setattr("profiles.keycloak_integration._keycloak_admin_client", None)

assert get_user_credential_types("dummy_user_id") == set()


@pytest.mark.parametrize(
"mock_return_value, expected_result",
[
([], set()),
([{"type": "password"}], {"password"}),
([{"type": "password"}, {"type": "otp"}], {"password", "otp"}),
],
)
def test_get_user_credential_types_with_data(
mock_keycloak_admin_client, mock_return_value, expected_result
):
mock_keycloak_admin_client.get_user_credentials.return_value = mock_return_value

assert get_user_credential_types("dummy_user_id") == expected_result


def test_get_user_credential_types_user_not_found(mock_keycloak_admin_client):
mock_keycloak_admin_client.get_user_credentials.side_effect = UserNotFoundError

assert get_user_credential_types("dummy_user_id") == set()


def test_get_user_credential_types_exception(mock_keycloak_admin_client):
mock_keycloak_admin_client.get_user_credentials.side_effect = Exception

with pytest.raises(Exception):
get_user_credential_types("dummy_user_id")


# Tests for get_user_login_methods
def test_get_user_login_methods(monkeypatch):
monkeypatch.setattr(
"profiles.keycloak_integration.get_user_credential_types",
lambda _: {"password"},
)
monkeypatch.setattr(
"profiles.keycloak_integration.get_user_identity_providers",
lambda _: {"provider1"},
)

assert get_user_login_methods("dummy_user_id") == {"password", "provider1"}
Loading
Loading