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-2319 | Check allowed data fields when querying profiles #494

Merged
merged 3 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions open_city_profile/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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"
FIELD_NOT_ALLOWED_ERROR = "FIELD_NOT_ALLOWED_ERROR"

# Service specific GDPR errors
SERVICE_GDPR_API_REQUEST_ERROR = "SERVICE_GDPR_API_REQUEST_ERROR"
Expand Down
14 changes: 14 additions & 0 deletions open_city_profile/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,17 @@ class TokenExchangeError(Exception):

class InsufficientLoaError(ProfileGraphQLError):
"""The requester has insufficient level of authentication to retrieve this data"""


class FieldNotAllowedError(ProfileGraphQLError):
"""The field is not allowed for the service.

Field does not exist in the service's allowed data fields (Service.allowed_data_fields).
"""

field_name: str = None

def __init__(self, *args, field_name=None, **kwargs):
super().__init__(*args, **kwargs)

self.field_name = field_name
33 changes: 33 additions & 0 deletions open_city_profile/graphene.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import logging
import uuid
from functools import partial

import graphene
from django.conf import settings
from django.forms import MultipleChoiceField
from django_filters import MultipleChoiceFilter
from graphene.utils.str_converters import to_snake_case
from graphene_django import DjangoObjectType
from graphene_django.forms.converter import convert_form_field
from graphene_django.types import ALL_FIELDS
from graphql_sync_dataloaders import SyncDataLoader
from parler.models import TranslatableModel

from open_city_profile.exceptions import FieldNotAllowedError, ServiceNotIdentifiedError
from profiles.loaders import (
addresses_by_profile_id_loader,
emails_by_profile_id_loader,
Expand Down Expand Up @@ -178,3 +181,33 @@ def __init_subclass_with_meta__(
_meta=_meta,
**options,
)


class AllowedDataFieldsMiddleware:

def resolve(self, next, root, info, **kwargs):
if getattr(root, "check_allowed_data_fields", False):
field_name = to_snake_case(getattr(info, "field_name", ""))

if not getattr(info.context, "service", False):
if settings.ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION:
raise ServiceNotIdentifiedError("Service not identified")

logging.warning(
"Allowed data field exception would occur: Service not identified. Field name: %s",
field_name,
)

if not root.is_field_allowed_for_service(field_name, info.context.service):
if settings.ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION:
raise FieldNotAllowedError(
"Field is not allowed for service.", field_name=field_name
)

logging.warning(
"Allowed data field exception would occur. Field (%s) is not allowed for service %s.",
field_name,
info.context.service,
)

return next(root, info, **kwargs)
4 changes: 4 additions & 0 deletions open_city_profile/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
AUDIT_LOG_LOGGER_FILENAME=(str, ""),
AUDIT_LOG_TO_DB_ENABLED=(bool, False),
OPEN_CITY_PROFILE_LOG_LEVEL=(str, None),
ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION=(bool, False),
ENABLE_GRAPHIQL=(bool, False),
ENABLE_GRAPHQL_INTROSPECTION=(bool, False),
GRAPHQL_QUERY_DEPTH_LIMIT=(int, 12),
Expand Down Expand Up @@ -174,6 +175,8 @@

GRAPHQL_QUERY_DEPTH_LIMIT = env("GRAPHQL_QUERY_DEPTH_LIMIT")

ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION = env("ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION")

INSTALLED_APPS = [
"helusers.apps.HelusersConfig",
"open_city_profile.apps.OpenCityProfileAdminConfig",
Expand Down Expand Up @@ -288,6 +291,7 @@
"SCHEMA": "open_city_profile.schema.schema",
"MIDDLEWARE": [
# NOTE: Graphene runs its middlewares in reverse order!
"open_city_profile.graphene.AllowedDataFieldsMiddleware",
"open_city_profile.graphene.JWTMiddleware",
"open_city_profile.graphene.GQLDataLoaders",
],
Expand Down
20 changes: 18 additions & 2 deletions open_city_profile/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
UserFactory,
)
from open_city_profile.views import GraphQLView
from services.tests.factories import ServiceFactory
from services.models import Service
from services.tests.factories import AllowedDataFieldFactory, ServiceFactory

_not_provided = object()

Expand All @@ -34,6 +35,7 @@ def execute(
auth_token_payload=None,
service=_not_provided,
context=None,
allowed_data_fields: list[str] = None,
**kwargs,
):
"""
Expand Down Expand Up @@ -63,9 +65,18 @@ def execute(
context.service = None

if service is _not_provided:
context.service = ServiceFactory(name="profile", is_profile_service=True)
service = Service.objects.filter(is_profile_service=True).first()
if not service:
service = ServiceFactory(name="profile", is_profile_service=True)

context.service = service
elif service:
context.service = service
if allowed_data_fields:
for field_name in allowed_data_fields:
context.service.allowed_data_fields.add(
AllowedDataFieldFactory(field_name=field_name)
)

return super().execute(
*args,
Expand Down Expand Up @@ -209,3 +220,8 @@ def unix_timestamp_now():
@pytest.fixture(params=[None, ""])
def empty_string_value(request):
return request.param


@pytest.fixture(autouse=True)
def enable_allowed_data_fields_restriction(settings):
settings.ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION = True
20 changes: 19 additions & 1 deletion open_city_profile/tests/graphql_test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
from django.utils.crypto import get_random_string
from jose import jwt

from services.tests.factories import ServiceClientIdFactory
from services.tests.factories import (
AllowedDataFieldFactory,
ServiceClientIdFactory,
ServiceConnectionFactory,
)

from .conftest import get_unix_timestamp_now
from .keys import rsa_key

AUDIENCE = getattr(settings, "OIDC_API_TOKEN_AUTH")["AUDIENCE"]
ISSUER = getattr(settings, "OIDC_API_TOKEN_AUTH")["ISSUER"]
if isinstance(ISSUER, list):
ISSUER = ISSUER[0]

CONFIG_URL = f"{ISSUER}/.well-known/openid-configuration"
JWKS_URL = f"{ISSUER}/jwks"
Expand Down Expand Up @@ -129,6 +135,18 @@ def do_graphql_call_as_user(
service_client_id = ServiceClientIdFactory(
service__service_type=None, service__is_profile_service=True
)
if getattr(user, "profile", None):
ServiceConnectionFactory(
profile=user.profile, service=service_client_id.service
)
service_client_id.service.allowed_data_fields.add(
AllowedDataFieldFactory(field_name="name"),
AllowedDataFieldFactory(field_name="address"),
AllowedDataFieldFactory(field_name="email"),
AllowedDataFieldFactory(field_name="phone"),
AllowedDataFieldFactory(field_name="personalidentitycode"),
)

elif service:
service_client_id = service.client_ids.first()

Expand Down
8 changes: 8 additions & 0 deletions open_city_profile/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CONNECTED_SERVICE_DELETION_FAILED_ERROR,
CONNECTED_SERVICE_DELETION_NOT_ALLOWED_ERROR,
DATA_CONFLICT_ERROR,
FIELD_NOT_ALLOWED_ERROR,
GENERAL_ERROR,
INSUFFICIENT_LOA_ERROR,
INVALID_EMAIL_FORMAT_ERROR,
Expand All @@ -35,6 +36,7 @@
ConnectedServiceDeletionFailedError,
ConnectedServiceDeletionNotAllowedError,
DataConflictError,
FieldNotAllowedError,
InsufficientLoaError,
InvalidEmailFormatError,
MissingGDPRApiTokenError,
Expand Down Expand Up @@ -75,6 +77,7 @@
ServiceConnectionDoesNotExist: SERVICE_CONNECTION_DOES_NOT_EXIST_ERROR,
ServiceNotIdentifiedError: SERVICE_NOT_IDENTIFIED_ERROR,
InsufficientLoaError: INSUFFICIENT_LOA_ERROR,
FieldNotAllowedError: FIELD_NOT_ALLOWED_ERROR,
}

sentry_ignored_errors = (
Expand Down Expand Up @@ -177,4 +180,9 @@ def format_error(error):
if "code" not in formatted_error["extensions"]:
formatted_error["extensions"]["code"] = error_code

if error_code == FIELD_NOT_ALLOWED_ERROR:
formatted_error["extensions"]["field_name"] = getattr(
error.original_error, "field_name", ""
)

return formatted_error
76 changes: 73 additions & 3 deletions profiles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from encrypted_fields import fields
from enumfields import EnumField

from services.models import ServiceConnection
from services.models import Service, ServiceConnection
from users.models import User
from utils.fields import (
NullToEmptyCharField,
Expand All @@ -28,7 +28,47 @@
)


class Profile(UUIDModel, SerializableMixin):
class AllowedDataFieldsMixin:
"""
Mixin class for checking allowed data fields per service.

`allowed_data_fields_map` is a dictionary where the key is the `field_name` of the allowed data field
`allowed_data_fields.json` and the value is an iterable of django model's field names that the `field_name`
describes. For example, if the `field_name` is `name`, the value could be `("first_name", "last_name")`.
e.g:
allowed_data_fields_map = {
"name": ("first_name", "last_name", "nickname"),
"personalidentitycode": ("national_identification_number",),
"address": ("address", "postal_code", "city", "country_code")
}

`always_allow_fields`: Since connections are not defined in `allowed_data_fields.json` they should be
defined here. If the field is connection and the node does not inherit this mixin the data will be available
to all services.
"""

allowed_data_fields_map = {}
always_allow_fields = ["id", "service_connections"]
check_allowed_data_fields = True

@classmethod
def is_field_allowed_for_service(cls, field_name: str, service: Service):
if not service:
raise ValueError("No service identified")

if field_name in cls.always_allow_fields:
return True

allowed_data_fields = service.allowed_data_fields.values_list(
"field_name", flat=True
)
return any(
field_name in cls.allowed_data_fields_map.get(allowed_data_field, [])
for allowed_data_field in allowed_data_fields
)


class Profile(UUIDModel, SerializableMixin, AllowedDataFieldsMixin):
user = models.OneToOneField(User, on_delete=models.PROTECT, null=True, blank=True)
first_name = NullToEmptyCharField(max_length=150, blank=True, db_index=True)
last_name = NullToEmptyCharField(max_length=150, blank=True, db_index=True)
Expand Down Expand Up @@ -63,6 +103,24 @@ class Meta:
)
audit_log = True

# AllowedDataField configs
allowed_data_fields_map = {
"name": (
"first_name",
"last_name",
"nickname",
),
"email": ("emails", "primary_email"),
"phone": ("phones", "primary_phone"),
"address": ("addresses", "primary_address"),
"personalidentitycode": ("sensitivedata",),
}
always_allow_fields = AllowedDataFieldsMixin.always_allow_fields + [
"verified_personal_information",
"language",
"contact_method",
]

def resolve_profile(self):
return self

Expand Down Expand Up @@ -178,7 +236,7 @@ def get_national_identification_number_hash_key():
return settings.SALT_NATIONAL_IDENTIFICATION_NUMBER


class VerifiedPersonalInformation(SerializableMixin):
class VerifiedPersonalInformation(SerializableMixin, AllowedDataFieldsMixin):
profile = models.OneToOneField(
Profile, on_delete=models.CASCADE, related_name="verified_personal_information"
)
Expand Down Expand Up @@ -237,6 +295,18 @@ class VerifiedPersonalInformation(SerializableMixin):
)
audit_log = True

allowed_data_fields_map = {
"name": ("first_name", "last_name", "given_name"),
"personalidentitycode": ("national_identification_number",),
"address": (
"municipality_of_residence",
"municipality_of_residence_number",
"permanent_address",
"temporary_address",
"permanent_foreign_address",
),
}

class Meta:
permissions = [
(
Expand Down
59 changes: 59 additions & 0 deletions profiles/tests/test_allowed_data_field_restriction_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from unittest import mock

from profiles.tests.factories import ProfileFactory, SensitiveDataFactory
from services.tests.factories import AllowedDataFieldFactory, ServiceConnectionFactory


def test_enable_allowed_data_fields_restriction_flag_false_shows_data(
user_gql_client, service, settings
):
settings.ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION = False
profile = ProfileFactory(user=user_gql_client.user)
ServiceConnectionFactory(profile=profile, service=service)
service.allowed_data_fields.add(AllowedDataFieldFactory(field_name="name"))
SensitiveDataFactory(profile=profile)

query = """
{
myProfile {
firstName
sensitivedata {
ssn
}
}
}
"""

executed = user_gql_client.execute(query, service=service)

assert executed["data"]["myProfile"]["firstName"] == profile.first_name
assert (
executed["data"]["myProfile"]["sensitivedata"]["ssn"]
== profile.sensitivedata.ssn
)


@mock.patch("logging.warning")
def test_enable_allowed_data_fields_restriction_flag_logs_warning_if_access_to_restricted_field(
mock_log, user_gql_client, service, settings
):
settings.ENABLE_ALLOWED_DATA_FIELDS_RESTRICTION = False
profile = ProfileFactory(user=user_gql_client.user)
ServiceConnectionFactory(profile=profile, service=service)
service.allowed_data_fields.add(AllowedDataFieldFactory(field_name="name"))
SensitiveDataFactory(profile=profile)

query = """
{
myProfile {
firstName
sensitivedata {
ssn
}
}
}
"""

user_gql_client.execute(query, service=service)

assert mock_log.call_count == 1
Loading
Loading