Skip to content

Commit 093403e

Browse files
committed
Add Dataspace FK validation on Dataspace and DejacodeUser models
Signed-off-by: tdruez <[email protected]>
1 parent a3435be commit 093403e

File tree

3 files changed

+77
-26
lines changed

3 files changed

+77
-26
lines changed

dje/models.py

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from django.contrib.admin.models import CHANGE
2323
from django.contrib.admin.models import DELETION
2424
from django.contrib.admin.models import LogEntry
25-
from django.contrib.auth import get_user_model
2625
from django.contrib.auth.models import AbstractUser
2726
from django.contrib.auth.models import BaseUserManager
2827
from django.contrib.auth.models import Group
@@ -90,6 +89,41 @@ def is_content_type_related(model_class):
9089
)
9190

9291

92+
class DataspaceForeignKeyValidationMixin:
93+
"""Mixin that enforces all related objects share the same Dataspace as self."""
94+
95+
def save(self, *args, **kwargs):
96+
"""Validate all foreign keys share the same Dataspace before saving."""
97+
self._validate_fk_dataspace()
98+
super().save(*args, **kwargs)
99+
100+
def _validate_fk_dataspace(self):
101+
"""Check that all foreign key objects share this instance's Dataspace."""
102+
# For these model classes, related objects can still be saved even if
103+
# they have a dataspace which is not the current one.
104+
allowed_models = [Dataspace, DejacodeUser, ContentType]
105+
106+
for fk_field in self.local_foreign_fields:
107+
if fk_field.related_model in allowed_models:
108+
continue
109+
110+
fk_field_value = getattr(self, fk_field.name, None)
111+
if fk_field_value and fk_field_value.dataspace != self.dataspace:
112+
raise ValueError(
113+
f'Foreign key field "{fk_field.name}": related object "{fk_field_value}" '
114+
f'has Dataspace "{fk_field_value.dataspace}", expected "{self.dataspace}"'
115+
)
116+
117+
def _get_local_foreign_fields(self):
118+
"""
119+
Return a list of ForeignKey type fields of the model.
120+
GenericForeignKey are not included, filtered out with field.concrete
121+
"""
122+
return [field for field in self._meta.get_fields() if field.many_to_one and field.concrete]
123+
124+
local_foreign_fields = property(_get_local_foreign_fields)
125+
126+
93127
class DataspaceManager(models.Manager):
94128
def get_by_natural_key(self, name):
95129
return self.get(name=name)
@@ -414,7 +448,7 @@ def tab_permissions_enabled(self):
414448
return bool(self.get_configuration("tab_permissions"))
415449

416450

417-
class DataspaceConfiguration(models.Model):
451+
class DataspaceConfiguration(DataspaceForeignKeyValidationMixin, models.Model):
418452
dataspace = models.OneToOneField(
419453
to="dje.Dataspace",
420454
on_delete=models.CASCADE,
@@ -756,7 +790,7 @@ def exclude_locked_products(self):
756790
)
757791

758792

759-
class DataspacedModel(models.Model):
793+
class DataspacedModel(DataspaceForeignKeyValidationMixin, models.Model):
760794
"""Abstract base model for all models that are keyed by Dataspace."""
761795

762796
dataspace = models.ForeignKey(
@@ -848,19 +882,6 @@ def save(self, *args, **kwargs):
848882
# It needs to be poped before calling the super().save()
849883
kwargs.pop("copy", None)
850884

851-
# For these model classes, related objects can still be saved even if
852-
# they have a dataspace which is not the current one.
853-
allowed_models = [Dataspace, get_user_model(), ContentType]
854-
855-
for field in self.local_foreign_fields:
856-
if field.related_model not in allowed_models:
857-
attr_value = getattr(self, field.name)
858-
if attr_value and attr_value.dataspace != self.dataspace:
859-
raise ValueError(
860-
f'The Dataspace of the related object: "{attr_value}" '
861-
f'is not "{self.dataspace}"'
862-
)
863-
864885
self.clean_extra_spaces_in_identifier_fields()
865886
super().save(*args, **kwargs)
866887

@@ -1089,15 +1110,6 @@ def urn_link(self):
10891110
if urn:
10901111
return format_html('<a href="{}">{}</a>', reverse("urn_resolve", args=[urn]), urn)
10911112

1092-
def _get_local_foreign_fields(self):
1093-
"""
1094-
Return a list of ForeignKey type fields of the model.
1095-
GenericForeignKey are not included, filtered out with field.concrete
1096-
"""
1097-
return [field for field in self._meta.get_fields() if field.many_to_one and field.concrete]
1098-
1099-
local_foreign_fields = property(_get_local_foreign_fields)
1100-
11011113
@classmethod
11021114
def get_identifier_fields(cls, *args, **kwargs):
11031115
"""
@@ -1678,7 +1690,7 @@ def get_vulnerability_notifications_users(self, dataspace):
16781690
)
16791691

16801692

1681-
class DejacodeUser(AbstractUser):
1693+
class DejacodeUser(DataspaceForeignKeyValidationMixin, AbstractUser):
16821694
uuid = models.UUIDField(
16831695
_("UUID"),
16841696
default=uuid.uuid4,

dje/tests/test_models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from dje.models import get_unsecured_manager
2121
from dje.models import is_dataspace_related
2222
from dje.models import is_secured
23+
from dje.tests import create
2324
from organization.models import Owner
2425
from organization.models import Subowner
2526

@@ -222,6 +223,28 @@ def test_dataspace_has_configuration(self):
222223
DataspaceConfiguration.objects.create(dataspace=self.dataspace)
223224
self.assertTrue(self.dataspace.has_configuration)
224225

226+
def test_dataspace_configuration_model_foreign_key_validation(self):
227+
layout_dataspace = create("CardLayout", self.dataspace)
228+
layout_alternate = create("CardLayout", self.alternate_dataspace)
229+
230+
expected_message = 'has Dataspace "alternate", expected "nexB"'
231+
with self.assertRaisesMessage(ValueError, expected_message):
232+
DataspaceConfiguration.objects.create(
233+
dataspace=self.dataspace,
234+
homepage_layout=layout_alternate,
235+
)
236+
237+
with self.assertRaisesMessage(ValueError, expected_message):
238+
self.dataspace.set_configuration("homepage_layout", layout_alternate)
239+
240+
self.dataspace.set_configuration("homepage_layout", layout_dataspace)
241+
242+
configuration = self.dataspace.get_configuration()
243+
244+
with self.assertRaisesMessage(ValueError, expected_message):
245+
configuration.homepage_layout = layout_alternate
246+
configuration.save()
247+
225248
def test_dataspace_tab_permissions_enabled(self):
226249
self.assertFalse(self.dataspace.tab_permissions_enabled)
227250

dje/tests/test_user.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ def test_user_password_reset_flow(self):
627627
class DejaCodeUserModelTestCase(TestCase):
628628
def setUp(self):
629629
self.dataspace = Dataspace.objects.create(name="nexB")
630+
self.alternate_dataspace = Dataspace.objects.create(name="alternate")
630631

631632
def test_user_model_queryset_manager(self):
632633
active = create_user("active", self.dataspace)
@@ -725,3 +726,18 @@ def test_user_model_serialize_user_data(self):
725726
"dataspace": "nexB",
726727
}
727728
self.assertEqual(expected, user.serialize_user_data())
729+
730+
def test_user_model_foreign_key_validation(self):
731+
layout_dataspace = create("CardLayout", self.dataspace)
732+
layout_alternate = create("CardLayout", self.alternate_dataspace)
733+
734+
expected_message = 'has Dataspace "alternate", expected "nexB"'
735+
with self.assertRaisesMessage(ValueError, expected_message):
736+
create_user("active", self.dataspace, homepage_layout=layout_alternate)
737+
738+
user = create_user("active", self.dataspace, homepage_layout=layout_dataspace)
739+
self.assertTrue(user.id)
740+
741+
with self.assertRaisesMessage(ValueError, expected_message):
742+
user.homepage_layout = layout_alternate
743+
user.save()

0 commit comments

Comments
 (0)