From dbc36596335e8421d6a2ad293058064e4c45b887 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Fri, 15 Nov 2024 15:26:37 +0200 Subject: [PATCH 1/4] Add GDPR API for fetching personal data tied to a User --- forms/models/form.py | 10 ++++++++- gdpr/__init__.py | 0 gdpr/tests/test_gdpr_permission.py | 22 +++++++++++++++++++ gdpr/utils.py | 9 ++++++++ gdpr/views.py | 17 +++++++++++++++ mvj/settings.py | 14 ++++++++++++ mvj/urls.py | 35 +++++++++++++++++++++++------- plotsearch/models/plot_search.py | 16 ++++++++++++-- requirements.in | 1 + requirements.txt | 17 ++++++++++++++- users/models.py | 15 ++++++++++++- 11 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 gdpr/__init__.py create mode 100644 gdpr/tests/test_gdpr_permission.py create mode 100644 gdpr/utils.py create mode 100644 gdpr/views.py diff --git a/forms/models/form.py b/forms/models/form.py index 002042d4..aa71430c 100755 --- a/forms/models/form.py +++ b/forms/models/form.py @@ -2,6 +2,7 @@ from django.db.models.fields.json import JSONField from django.utils.translation import gettext_lazy as _ from enumfields import EnumField +from helsinki_gdpr.models import SerializableMixin from users.models import User @@ -206,7 +207,7 @@ def get_attachment_file_upload_to(instance, filename): ) -class Attachment(models.Model): +class Attachment(SerializableMixin, models.Model): name = models.CharField(max_length=255) attachment = models.FileField(upload_to=get_attachment_file_upload_to) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Time created")) @@ -216,6 +217,13 @@ class Attachment(models.Model): field = models.ForeignKey(Field, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) + # GDPR API + serialize_fields = ( + {"name": "name"}, + {"name": "attachment"}, + {"name": "created_at"}, + ) + recursive_get_related_skip_relations = [ "user", ] diff --git a/gdpr/__init__.py b/gdpr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gdpr/tests/test_gdpr_permission.py b/gdpr/tests/test_gdpr_permission.py new file mode 100644 index 00000000..3d5d7945 --- /dev/null +++ b/gdpr/tests/test_gdpr_permission.py @@ -0,0 +1,22 @@ +from helusers.authz import UserAuthorization +from requests import Request + +from gdpr.views import AmrPermission, MvjGDPRAPIView + + +def test_amr_permission_has_permission(): + request = Request() + request.auth = UserAuthorization(user=None, api_token_payload={"amr": ["suomi_fi"]}) + view = MvjGDPRAPIView() + permission = AmrPermission() + assert permission.has_permission(request, view) is True + + +def test_amr_permission_has_not_permission(): + request = Request() + request.auth = UserAuthorization( + user=None, api_token_payload={"amr": ["helsinki_tunnus"]} + ) + view = MvjGDPRAPIView() + permission = AmrPermission() + assert permission.has_permission(request, view) is False diff --git a/gdpr/utils.py b/gdpr/utils.py new file mode 100644 index 00000000..6f099a36 --- /dev/null +++ b/gdpr/utils.py @@ -0,0 +1,9 @@ +from users.models import User + + +def get_user(user: User) -> User: + """ + Get the user provider, as User is the GDPR API root model. GDPR API implementation + by default attempts to get it from the root models user attribute. + """ + return user diff --git a/gdpr/views.py b/gdpr/views.py new file mode 100644 index 00000000..aadfc05b --- /dev/null +++ b/gdpr/views.py @@ -0,0 +1,17 @@ +from helsinki_gdpr.views import GDPRAPIView +from rest_framework.permissions import BasePermission +from rest_framework.request import Request + + +class AmrPermission(BasePermission): + """Authentication Method Reference (amr)""" + + def has_permission(self, request: Request, _view: "MvjGDPRAPIView") -> bool: + amrs: list[str] = request.auth.data.get("amr", []) + allowed_amr = "suomi_fi" + has_allowed_amr = allowed_amr in amrs + return has_allowed_amr + + +class MvjGDPRAPIView(GDPRAPIView): + permission_classes = GDPRAPIView.permission_classes + [AmrPermission] diff --git a/mvj/settings.py b/mvj/settings.py index bb4b7edf..73449d48 100644 --- a/mvj/settings.py +++ b/mvj/settings.py @@ -103,6 +103,12 @@ def get_git_revision_hash(): LASKE_PAYMENTS_DIRECTORY=(str, ""), LASKE_PAYMENTS_KEY_TYPE=(str, ""), LASKE_PAYMENTS_KEY=(bytes, ""), + GDPR_API_URL_PATTERN=(str, "v1/profiles/"), + GDPR_API_MODEL=(str, "users.User"), + GDPR_API_MODEL_LOOKUP=(str, "uuid"), + GDPR_API_QUERY_SCOPE=(str, ""), + GDPR_API_DELETE_SCOPE=(str, ""), + GDPR_API_USER_PROVIDER=(str, ""), ) env_file = project_root(".env") @@ -325,6 +331,14 @@ def get_git_revision_hash(): "REQUIRE_API_SCOPE_FOR_AUTHENTICATION": env.bool("TOKEN_AUTH_REQUIRE_SCOPE_PREFIX"), } +# https://github.com/City-of-Helsinki/helsinki-profile-gdpr-api +GDPR_API_URL_PATTERN = env("GDPR_API_URL_PATTERN") +GDPR_API_MODEL = env("GDPR_API_MODEL") +GDPR_API_MODEL_LOOKUP = env("GDPR_API_MODEL_LOOKUP") +GDPR_API_QUERY_SCOPE = env("GDPR_API_QUERY_SCOPE") +GDPR_API_DELETE_SCOPE = env("GDPR_API_DELETE_SCOPE") +GDPR_API_USER_PROVIDER = env("GDPR_API_USER_PROVIDER") + LASKE_VALUES = { "distribution_channel": "10", "division": "10", diff --git a/mvj/urls.py b/mvj/urls.py index 00585f29..0d8d7bc0 100755 --- a/mvj/urls.py +++ b/mvj/urls.py @@ -20,6 +20,7 @@ MeetingMemoViewset, TargetStatusViewset, ) +from gdpr.views import MvjGDPRAPIView from leasing.api_functions import CalculateIncreaseWith360DayCalendar from leasing.report.viewset import ReportViewSet from leasing.views import CloudiaProxy, VirreProxy, ktj_proxy @@ -364,7 +365,17 @@ CalculateIncreaseWith360DayCalendar.as_view(), ), ] - +gdpr_urls = [ + path( + getattr( + settings, + "GDPR_API_URL_PATTERN", + "v1/profiles/", + ), + MvjGDPRAPIView.as_view(), + name="gdpr_v1", + ) +] additional_pub_api_paths = [ path( "direct_reservation_to_favourite//", @@ -372,20 +383,28 @@ name="pub_direct_reservation_to_favourite", ), path("plot_search_ui/", PlotSearchUIDataView.as_view(), name="pub_plot_search_ui"), + # Enables oidc backchannel logout, requires setting `HELUSERS_BACK_CHANNEL_LOGOUT_ENABLED = True` + # to be useful + path("helauth/", include("helusers.urls")), + # GDPR API + path( + "gdpr-api/", + include( + ( + gdpr_urls, + "gdpr", # Namespace + ), + ), + ), ] api_urls = router.urls + additional_api_paths + # Path: v1/pub/ pub_api_urls = [ path( "pub/", - include( - pub_router.urls - + additional_pub_api_paths - # Enables oidc backchannel logout, requires setting `HELUSERS_BACK_CHANNEL_LOGOUT_ENABLED = True` - # to be useful - + [path("helauth/", include("helusers.urls"))] - ), + include(pub_router.urls + additional_pub_api_paths), ) ] credit_integration_urls = [ diff --git a/plotsearch/models/plot_search.py b/plotsearch/models/plot_search.py index fe4b893b..050e2d50 100755 --- a/plotsearch/models/plot_search.py +++ b/plotsearch/models/plot_search.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext_lazy as _ from django.utils.translation import pgettext_lazy from enumfields import EnumField +from helsinki_gdpr.models import SerializableMixin from rest_framework.serializers import ValidationError from safedelete.models import SOFT_DELETE, SafeDeleteModel @@ -482,7 +483,7 @@ class AreaSearchStatus(models.Model): ] -class AreaSearch(models.Model): +class AreaSearch(SerializableMixin, models.Model): # In Finnish: aluehaku geometry = gmodels.MultiPolygonField( @@ -566,6 +567,14 @@ def lessor_name(self): blank=True, ) + # GDPR API + serialize_fields = ( + {"name": "address"}, + {"name": "received_date"}, + # plotsearch.AreaSearchAttachment + {"name": "area_search_attachments"}, + ) + recursive_get_related_skip_relations = [ "related_plot_applications", "areasearchattachment", @@ -598,7 +607,7 @@ def get_area_search_attachment_upload_to(instance, filename): ) -class AreaSearchAttachment(NameModel): +class AreaSearchAttachment(SerializableMixin, NameModel): attachment = models.FileField( upload_to=get_area_search_attachment_upload_to, null=True, blank=True ) @@ -615,6 +624,9 @@ class AreaSearchAttachment(NameModel): blank=True, ) + # GDPR API + serialize_fields = ({"name": "attachment"}, {"name": "created_at"}) + recursive_get_related_skip_relations = [ "user", ] diff --git a/requirements.in b/requirements.in index 602d4368..83dab3ee 100644 --- a/requirements.in +++ b/requirements.in @@ -24,6 +24,7 @@ djangorestframework~=3.15.2 djangorestframework-gis~=1.1.0 drf-yasg[validation]~=1.21.8 docxtpl~=0.18.0 +helsinki-profile-gdpr-api~=0.2.0 oracledb~=2.1.0 psycopg[binary]~=3.2.3 pysftp~=0.2.9 diff --git a/requirements.txt b/requirements.txt index 5f8c79de..3e369008 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,8 @@ attrs==23.2.0 # jsonschema # referencing # zeep +authlib==1.3.2 + # via drf-oidc-auth babel==2.15.0 # via docxcompose bcrypt==4.1.3 @@ -46,6 +48,8 @@ click==8.1.7 # via pyhanko cryptography==42.0.7 # via + # authlib + # drf-oidc-auth # oracledb # paramiko # pyhanko @@ -77,7 +81,9 @@ django==4.2.16 # django-stubs-ext # django-xhtml2pdf # djangorestframework + # drf-oidc-auth # drf-yasg + # helsinki-profile-gdpr-api django-admin-rangefilter==0.13.2 # via -r requirements.in django-anymail==12.0 @@ -99,7 +105,9 @@ django-filter==24.3 # -r requirements.in # djangorestframework-gis django-helusers==0.13.0 - # via -r requirements.in + # via + # -r requirements.in + # helsinki-profile-gdpr-api django-model-utils==5.0.0 # via -r requirements.in django-modeltranslation==0.19.10 @@ -128,17 +136,23 @@ djangorestframework==3.15.2 # via # -r requirements.in # djangorestframework-gis + # drf-oidc-auth # drf-yasg + # helsinki-profile-gdpr-api djangorestframework-gis==1.1 # via -r requirements.in docxcompose==1.4.0 # via docxtpl docxtpl==0.18.0 # via -r requirements.in +drf-oidc-auth==3.0.0 + # via helsinki-profile-gdpr-api drf-yasg[validation]==1.21.8 # via -r requirements.in ecdsa==0.19.0 # via python-jose +helsinki-profile-gdpr-api==0.2.0 + # via -r requirements.in html5lib==1.1 # via xhtml2pdf idna==3.7 @@ -251,6 +265,7 @@ requests==2.32.3 # -r requirements.in # django-anymail # django-helusers + # drf-oidc-auth # pyhanko # pyhanko-certvalidator # requests-file diff --git a/users/models.py b/users/models.py index f963299f..5eb0db76 100644 --- a/users/models.py +++ b/users/models.py @@ -5,13 +5,26 @@ from django.core.cache import cache from django.db import models, transaction from django.utils.translation import gettext_lazy as _ +from helsinki_gdpr.models import SerializableMixin from helusers.models import AbstractUser, ADGroupMapping from rest_framework.authtoken.models import Token -class User(AbstractUser): +class User(AbstractUser, SerializableMixin): service_units = models.ManyToManyField("leasing.ServiceUnit", related_name="users") + # GDPR API, meant for PlotSearch app users + serialize_fields = ( + {"name": "uuid"}, + {"name": "first_name"}, + {"name": "last_name"}, + {"name": "email"}, + # forms.Attachment + {"name": "attachment"}, + # plotsearch.AreaSearch + {"name": "areasearch_set"}, + ) + recursive_get_related_skip_relations = [ "auth_token", "logentry", From 0cf2678edd553d1f909a227c0748da1e50ada91f Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Fri, 15 Nov 2024 16:01:39 +0200 Subject: [PATCH 2/4] Upon public user deletion set related foreignkeys null --- ...alter_answer_user_alter_attachment_user.py | 34 +++++++++++++ forms/models/form.py | 4 +- ...r_areasearchattachment_options_and_more.py | 48 +++++++++++++++++++ plotsearch/models/plot_search.py | 8 ++-- 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 forms/migrations/0026_alter_answer_user_alter_attachment_user.py create mode 100644 plotsearch/migrations/0037_alter_areasearchattachment_options_and_more.py diff --git a/forms/migrations/0026_alter_answer_user_alter_attachment_user.py b/forms/migrations/0026_alter_answer_user_alter_attachment_user.py new file mode 100644 index 00000000..f4c89b1a --- /dev/null +++ b/forms/migrations/0026_alter_answer_user_alter_attachment_user.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.16 on 2024-11-15 14:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("forms", "0025_alter_answeropeningrecord_openers"), + ] + + operations = [ + migrations.AlterField( + model_name="answer", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="attachment", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/forms/models/form.py b/forms/models/form.py index aa71430c..5c362193 100755 --- a/forms/models/form.py +++ b/forms/models/form.py @@ -162,7 +162,7 @@ class Answer(models.Model): """ form = models.ForeignKey(Form, on_delete=models.PROTECT) - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) created_at = models.DateTimeField(auto_now=True) ready = models.BooleanField(default=False) @@ -215,7 +215,7 @@ class Attachment(SerializableMixin, models.Model): answer = models.ForeignKey(Answer, on_delete=models.CASCADE, null=True, blank=True) field = models.ForeignKey(Field, on_delete=models.CASCADE) - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) # GDPR API serialize_fields = ( diff --git a/plotsearch/migrations/0037_alter_areasearchattachment_options_and_more.py b/plotsearch/migrations/0037_alter_areasearchattachment_options_and_more.py new file mode 100644 index 00000000..f9e5d7d0 --- /dev/null +++ b/plotsearch/migrations/0037_alter_areasearchattachment_options_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2024-11-15 14:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("plotsearch", "0036_areasearch_service_unit"), + ] + + operations = [ + migrations.AlterModelOptions( + name="areasearchattachment", + options={}, + ), + migrations.AlterField( + model_name="areasearch", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="areasearchattachment", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="favourite", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/plotsearch/models/plot_search.py b/plotsearch/models/plot_search.py index 050e2d50..bc871c60 100755 --- a/plotsearch/models/plot_search.py +++ b/plotsearch/models/plot_search.py @@ -423,7 +423,7 @@ class MeetingMemo(models.Model): class Favourite(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) + user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) created_at = models.DateTimeField(auto_now_add=True, db_index=True) modified_at = models.DateTimeField(auto_now=True) @@ -532,7 +532,7 @@ def lessor_name(self): answer = models.OneToOneField( Answer, on_delete=models.CASCADE, null=True, related_name="area_search" ) - user = models.ForeignKey(User, on_delete=models.CASCADE, null=True) + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) # In Finnish: Käsittelijä preparer = models.ForeignKey( @@ -613,7 +613,9 @@ class AreaSearchAttachment(SerializableMixin, NameModel): ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Time created")) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+") + user = models.ForeignKey( + User, null=True, on_delete=models.SET_NULL, related_name="+" + ) # In Finnish: Aluehaut area_search = models.ForeignKey( From da73ffc801fc54823ed6d00ba5b08047f7b0a3a8 Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Fri, 15 Nov 2024 16:55:11 +0200 Subject: [PATCH 3/4] Add user data deletion to GDPR API --- gdpr/utils.py | 7 +++++++ mvj/settings.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/gdpr/utils.py b/gdpr/utils.py index 6f099a36..247d0613 100644 --- a/gdpr/utils.py +++ b/gdpr/utils.py @@ -7,3 +7,10 @@ def get_user(user: User) -> User: by default attempts to get it from the root models user attribute. """ return user + + +def delete_user_data(user: User, dry_run: bool) -> None: + """ + Delete user data. + """ + user.delete() diff --git a/mvj/settings.py b/mvj/settings.py index 73449d48..bdbb93e7 100644 --- a/mvj/settings.py +++ b/mvj/settings.py @@ -109,6 +109,7 @@ def get_git_revision_hash(): GDPR_API_QUERY_SCOPE=(str, ""), GDPR_API_DELETE_SCOPE=(str, ""), GDPR_API_USER_PROVIDER=(str, ""), + GDPR_API_DELETER=(str, "gdpr.utils.delete_user_data"), ) env_file = project_root(".env") @@ -338,6 +339,7 @@ def get_git_revision_hash(): GDPR_API_QUERY_SCOPE = env("GDPR_API_QUERY_SCOPE") GDPR_API_DELETE_SCOPE = env("GDPR_API_DELETE_SCOPE") GDPR_API_USER_PROVIDER = env("GDPR_API_USER_PROVIDER") +GDPR_API_DELETER = env("GDPR_API_DELETER") LASKE_VALUES = { "distribution_channel": "10", From 0a6ab06e2fada15ecc8c87c3d2b03ddf9e953c7f Mon Sep 17 00:00:00 2001 From: Henri Nieminen Date: Mon, 18 Nov 2024 14:21:28 +0000 Subject: [PATCH 4/4] Clarify delete_user_data and get_user functions of gdpr api --- gdpr/utils.py | 8 +++++++- mvj/settings.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/gdpr/utils.py b/gdpr/utils.py index 247d0613..cfccf869 100644 --- a/gdpr/utils.py +++ b/gdpr/utils.py @@ -1,3 +1,5 @@ +from helsinki_gdpr.views import DryRunException + from users.models import User @@ -5,6 +7,7 @@ def get_user(user: User) -> User: """ Get the user provider, as User is the GDPR API root model. GDPR API implementation by default attempts to get it from the root models user attribute. + This function is used by defining it as the value for setting `GDPR_API_USER_PROVIDER`. """ return user @@ -13,4 +16,7 @@ def delete_user_data(user: User, dry_run: bool) -> None: """ Delete user data. """ - user.delete() + if dry_run: + raise DryRunException("Dry run. Rollback delete transaction.") + else: + user.delete() diff --git a/mvj/settings.py b/mvj/settings.py index bdbb93e7..f7f6b640 100644 --- a/mvj/settings.py +++ b/mvj/settings.py @@ -108,7 +108,7 @@ def get_git_revision_hash(): GDPR_API_MODEL_LOOKUP=(str, "uuid"), GDPR_API_QUERY_SCOPE=(str, ""), GDPR_API_DELETE_SCOPE=(str, ""), - GDPR_API_USER_PROVIDER=(str, ""), + GDPR_API_USER_PROVIDER=(str, "gdpr.utils.get_user"), GDPR_API_DELETER=(str, "gdpr.utils.delete_user_data"), )