Skip to content

Commit

Permalink
🚧 [#4398] Check object ownership when creating submission
Browse files Browse the repository at this point in the history
if an initial_data_reference is passed for the product prefill flow
  • Loading branch information
stevenbal committed Oct 1, 2024
1 parent 7b20acc commit 879270d
Show file tree
Hide file tree
Showing 4 changed files with 305 additions and 1 deletion.
53 changes: 53 additions & 0 deletions src/openforms/contrib/objects_api/validators.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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")
8 changes: 8 additions & 0 deletions src/openforms/submissions/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
139 changes: 138 additions & 1 deletion src/openforms/submissions/tests/test_start_submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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())

0 comments on commit 879270d

Please sign in to comment.