diff --git a/src/openforms/contrib/objects_api/validators.py b/src/openforms/contrib/objects_api/validators.py index cde3d2a987..92f12a6ec5 100644 --- a/src/openforms/contrib/objects_api/validators.py +++ b/src/openforms/contrib/objects_api/validators.py @@ -1,19 +1,32 @@ from __future__ import annotations +import logging import warnings from functools import partial from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +from glom import glom +from requests.exceptions import RequestException +from rest_framework.exceptions import PermissionDenied + +from openforms.authentication.service import FORM_AUTH_SESSION_KEY +from openforms.contrib.objects_api.clients import ( + NoServiceConfigured, + get_objects_client, +) from openforms.contrib.zgw.clients.catalogi import Catalogus from openforms.contrib.zgw.validators import ( validate_catalogue_reference as _validate_catalogue_reference, ) +from openforms.forms.models import Form from .clients import get_catalogi_client from .models import ObjectsAPIGroupConfig +logger = logging.getLogger(__name__) + def validate_catalogue_reference( config: ObjectsAPIGroupConfig, @@ -78,3 +91,43 @@ def validate_document_type_references( if errors: raise ValidationError(errors) + + +def validate_object_ownership(form: Form, session, initial_data_reference: str) -> None: + auth_info = session.get(FORM_AUTH_SESSION_KEY) + if not auth_info: + raise PermissionDenied("Cannot pass data reference as anonymous user") + + object = None + for backend in form.registration_backends.filter(backend="objects_api"): + if not backend.options: + continue + + api_group = ObjectsAPIGroupConfig.objects.filter( + pk=backend.options.get("objects_api_group") + ).first() + if not api_group: + continue + + try: + with get_objects_client(api_group) as client: + try: + object = client.get_object(initial_data_reference) + break + except RequestException: + logger.exception( + "Something went wrong while trying to retrieve " + "object for product prefill permission check" + ) + except NoServiceConfigured: + logger.exception( + "Something went wrong while trying to create a client " + "for Objects API" + ) + + if not object: + raise PermissionDenied("Could not fetch object from initial data reference") + + # TODO should this path be configurable? + if glom(object["record"]["data"], auth_info["attribute"]) != auth_info["value"]: + raise PermissionDenied("User is not the owner of the referenced object") diff --git a/src/openforms/submissions/api/serializers.py b/src/openforms/submissions/api/serializers.py index 2c081bcb41..548c5c0a30 100644 --- a/src/openforms/submissions/api/serializers.py +++ b/src/openforms/submissions/api/serializers.py @@ -19,6 +19,7 @@ from csp_post_processor.drf.fields import CSPPostProcessedHTMLField from openforms.api.utils import mark_experimental from openforms.config.models import GlobalConfiguration +from openforms.contrib.objects_api.validators import validate_object_ownership from openforms.emails.utils import render_email_template, send_mail_html from openforms.formio.service import build_serializer from openforms.formio.utils import iter_components @@ -199,6 +200,13 @@ def create(self, validated_data): validated_data.pop("anonymous", None) return super().create(validated_data) + def validate(self, attrs: dict): + if initial_data_reference := attrs.get("initial_data_reference"): + session = self.context["request"].session + validate_object_ownership(attrs["form"], session, initial_data_reference) + + return attrs + def to_representation(self, instance): check_submission_logic(instance, unsaved_data=self.context.get("unsaved_data")) return super().to_representation(instance) diff --git a/src/openforms/submissions/tests/data/vcr_cassettes/SubmissionStartTests/SubmissionStartTests.test_start_submission_with_initial_data_reference_and_not_owner_of_object_raises_error.yaml b/src/openforms/submissions/tests/data/vcr_cassettes/SubmissionStartTests/SubmissionStartTests.test_start_submission_with_initial_data_reference_and_not_owner_of_object_raises_error.yaml new file mode 100644 index 0000000000..c394c1561f --- /dev/null +++ b/src/openforms/submissions/tests/data/vcr_cassettes/SubmissionStartTests/SubmissionStartTests.test_start_submission_with_initial_data_reference_and_not_owner_of_object_raises_error.yaml @@ -0,0 +1,106 @@ +interactions: +- request: + body: '{"type": "http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + "record": {"typeVersion": 1, "data": {"bsn": "111222333", "foo": "bar"}, "startAt": + "2024-09-23"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '194' + Content-Type: + - application/json + User-Agent: + - python-requests/2.32.2 + method: POST + uri: http://localhost:8002/api/v2/objects + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/41c3d83b-7e81-464c-b346-55a11de48e49","uuid":"41c3d83b-7e81-464c-b346-55a11de48e49","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-09-23","endAt":null,"registrationAt":"2024-09-23","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, POST, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Mon, 23 Sep 2024 13:49:45 GMT + Location: + - http://localhost:8002/api/v2/objects/41c3d83b-7e81-464c-b346-55a11de48e49 + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Authorization: + - Token 7657474c3d75f56ae0abd0d1bf7994b09964dca9 + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + User-Agent: + - python-requests/2.32.2 + method: GET + uri: http://localhost:8002/api/v2/objects/41c3d83b-7e81-464c-b346-55a11de48e49 + response: + body: + string: '{"url":"http://objects-web:8000/api/v2/objects/41c3d83b-7e81-464c-b346-55a11de48e49","uuid":"41c3d83b-7e81-464c-b346-55a11de48e49","type":"http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e","record":{"index":1,"typeVersion":1,"data":{"bsn":"111222333","foo":"bar"},"geometry":null,"startAt":"2024-09-23","endAt":null,"registrationAt":"2024-09-23","correctionFor":null,"correctedBy":null}}' + headers: + Allow: + - GET, PUT, PATCH, DELETE, HEAD, OPTIONS + Connection: + - keep-alive + Content-Crs: + - EPSG:4326 + Content-Length: + - '422' + Content-Type: + - application/json + Cross-Origin-Opener-Policy: + - same-origin + Date: + - Mon, 23 Sep 2024 13:49:46 GMT + Referrer-Policy: + - same-origin + Server: + - nginx/1.27.0 + Vary: + - origin + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/submissions/tests/test_start_submission.py b/src/openforms/submissions/tests/test_start_submission.py index ec46588ad2..4ed3579abb 100644 --- a/src/openforms/submissions/tests/test_start_submission.py +++ b/src/openforms/submissions/tests/test_start_submission.py @@ -14,6 +14,7 @@ See ``test_disabled_forms.py`` for more extensive tests around maintenance mode. """ +from pathlib import Path from unittest.mock import patch from django.test import override_settings, tag @@ -23,22 +24,30 @@ from rest_framework.test import APITestCase from openforms.authentication.service import FORM_AUTH_SESSION_KEY, AuthAttribute +from openforms.contrib.objects_api.clients import get_objects_client +from openforms.contrib.objects_api.helpers import prepare_data_for_registration +from openforms.contrib.objects_api.tests.factories import ObjectsAPIGroupConfigFactory from openforms.forms.tests.factories import ( FormFactory, + FormRegistrationBackendFactory, FormStepFactory, FormVariableFactory, ) +from openforms.utils.tests.vcr import OFVCRMixin from ..constants import SUBMISSIONS_SESSION_KEY, SubmissionValueVariableSources from ..models import Submission, SubmissionValueVariable +TEST_FILES = (Path(__file__).parent / "data").resolve() + @override_settings( CORS_ALLOW_ALL_ORIGINS=False, ALLOWED_HOSTS=["*"], CORS_ALLOWED_ORIGINS=["http://testserver.com"], ) -class SubmissionStartTests(APITestCase): +class SubmissionStartTests(OFVCRMixin, APITestCase): + VCR_TEST_FILES = TEST_FILES endpoint = reverse_lazy("api:submission-list") @classmethod @@ -247,3 +256,131 @@ def test_start_submission_with_initial_data_reference(self): self.assertEqual( submission.initial_data_reference, body["initialDataReference"] ) + + def test_start_submission_with_initial_data_reference_and_anonymous_raises_error( + self, + ): + body = { + "form": f"http://testserver.com{self.form_url}", + "formUrl": "http://testserver.com/my-form", + "initialDataReference": "of-or-3452fre3", + "anonymous": True, + } + + response = self.client.post(self.endpoint, body) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.json()["detail"], "Cannot pass data reference as anonymous user" + ) + self.assertFalse(Submission.objects.exists()) + + def test_start_submission_with_initial_data_reference_and_not_owner_of_object_raises_error( + self, + ): + # objects_api_group_unused = ObjectsAPIGroupConfigFactory.create() + objects_api_group_used = ObjectsAPIGroupConfigFactory.create( + for_test_docker_compose=True + ) + with get_objects_client(objects_api_group_used) as client: + object = client.create_object( + record_data=prepare_data_for_registration( + data={"bsn": "111222333", "foo": "bar"}, + objecttype_version=1, + ), + objecttype_url="http://objecttypes-web:8000/api/v2/objecttypes/8faed0fa-7864-4409-aa6d-533a37616a9e", + ) + + object_ref = object["uuid"] + + # unused_objects_backend = FormRegistrationBackendFactory.create( + # form=self.form, + # backend="objects_api", + # options={ + # "version": 2, + # "objecttype": "3edfdaf7-f469-470b-a391-bb7ea015bd6f", + # "iot_attachment": "", + # "objects_api_group": objects_api_group_unused.pk, + # # TODO these are probably not needed? + # # "variables_mapping": [], + # # "iot_submission_csv": "", + # "objecttype_version": 1, + # # "geometry_variable_key": "", + # # "iot_submission_report": "", + # # "upload_submission_csv": False, + # # "update_existing_object": True, + # }, + # ) + objects_backend_incorrect_api_group = FormRegistrationBackendFactory.create( + form=self.form, + backend="objects_api", + options={ + "version": 2, + "objecttype": "3edfdaf7-f469-470b-a391-bb7ea015bd6f", + "objects_api_group": 5, + "objecttype_version": 1, + }, + ) + irrelevant_backend = FormRegistrationBackendFactory.create( + form=self.form, backend="email" + ) + objects_backend = FormRegistrationBackendFactory.create( + form=self.form, + backend="objects_api", + options={ + "version": 2, + "objecttype": "3edfdaf7-f469-470b-a391-bb7ea015bd6f", + "objects_api_group": objects_api_group_used.pk, + "objecttype_version": 1, + }, + ) + + session = self.client.session + session[FORM_AUTH_SESSION_KEY] = { + "plugin": "digid", + "attribute": AuthAttribute.bsn, + "value": "123456782", + } + session.save() + + body = { + "form": f"http://testserver.com{self.form_url}", + "formUrl": "http://testserver.com/my-form", + "initialDataReference": object_ref, + } + + response = self.client.post(self.endpoint, body) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.json()["detail"], "User is not the owner of the referenced object" + ) + self.assertFalse(Submission.objects.exists()) + + def test_start_submission_with_initial_data_reference_and_no_backends_configured_raises_error( + self, + ): + FormRegistrationBackendFactory.create(form=self.form, backend="email") + + session = self.client.session + session[FORM_AUTH_SESSION_KEY] = { + "plugin": "digid", + "attribute": AuthAttribute.bsn, + "value": "123456782", + } + session.save() + + body = { + "form": f"http://testserver.com{self.form_url}", + "formUrl": "http://testserver.com/my-form", + "initialDataReference": "of-or-3452fre3", + } + + response = self.client.post(self.endpoint, body) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.json()["detail"], + "Could not fetch object from initial data reference", + ) + self.assertFalse(Submission.objects.exists())