Skip to content

Commit c987d00

Browse files
authored
Add Dataspace FK validation on Dataspace and DejacodeUser models (#431)
Signed-off-by: tdruez <[email protected]>
1 parent a3435be commit c987d00

File tree

6 files changed

+171
-32
lines changed

6 files changed

+171
-32
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ Release notes
1717
Prior to this change only "superusers" could see and use this feature.
1818
https://github.com/aboutcode-org/dejacode/issues/385
1919

20+
- Add Dataspace FK validation on Dataspace and DejacodeUser models.
21+
Assigning an object from another Dataspace will raise an error at the ``save()``
22+
level.
23+
Do not include the ``homepage_layout`` field on Dataspace "addition" form since the
24+
Dataspace does not exist yet.
25+
Display the ``homepage_layout`` field as read-only on the Dataspace and User change
26+
forms when the currently logged user is not looking at his own Dataspace.
27+
https://github.com/aboutcode-org/dejacode/issues/428
28+
2029
### Version 5.4.2
2130

2231
- Migrate the LDAP testing from using mockldap to slapdtest.

dje/admin.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,11 +1081,7 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
10811081
form = DataspaceConfigurationForm
10821082
verbose_name_plural = _("Configuration")
10831083
verbose_name = _("Dataspace configuration")
1084-
fieldsets = [
1085-
(
1086-
"",
1087-
{"fields": ("homepage_layout",)},
1088-
),
1084+
add_fieldsets = [
10891085
(
10901086
"AboutCode Integrations",
10911087
{
@@ -1142,8 +1138,25 @@ class DataspaceConfigurationInline(DataspacedFKMixin, admin.StackedInline):
11421138
},
11431139
),
11441140
]
1141+
# Do not include the Dataspace related FKs on addition as the Dataspace does not exist yet
1142+
fieldsets = [("", {"fields": ("homepage_layout",)})] + add_fieldsets
11451143
can_delete = False
11461144

1145+
def get_fieldsets(self, request, obj=None):
1146+
if not obj:
1147+
return self.add_fieldsets
1148+
return super().get_fieldsets(request, obj)
1149+
1150+
def get_readonly_fields(self, request, obj=None):
1151+
"""Only a user from the current Dataspace can edit Dataspace related FKs."""
1152+
readonly_fields = super().get_readonly_fields(request, obj)
1153+
1154+
dataspace = obj
1155+
if dataspace and dataspace != request.user.dataspace:
1156+
readonly_fields += ("homepage_layout",)
1157+
1158+
return readonly_fields
1159+
11471160

11481161
@admin.register(Dataspace, site=dejacode_site)
11491162
class DataspaceAdmin(
@@ -1433,7 +1446,6 @@ class DejacodeUserAdmin(
14331446
add_fieldsets = (
14341447
(None, {"fields": ("username", "dataspace")}),
14351448
(_("Personal information"), {"fields": ("email", "first_name", "last_name", "company")}),
1436-
(_("Profile"), {"fields": ("homepage_layout",)}),
14371449
(
14381450
_("Notifications"),
14391451
{
@@ -1448,6 +1460,7 @@ class DejacodeUserAdmin(
14481460
(_("Permissions"), {"fields": ("is_staff", "is_superuser", "groups")}),
14491461
)
14501462
fieldsets = add_fieldsets[:-1] + (
1463+
(_("Profile"), {"fields": ("homepage_layout",)}),
14511464
(_("Permissions"), {"fields": ("is_active", "is_staff", "is_superuser", "groups")}),
14521465
(_("Important dates"), {"fields": ("last_login", "last_api_access", "date_joined")}),
14531466
)
@@ -1516,6 +1529,15 @@ def formfield_for_foreignkey(self, db_field, request=None, **kwargs):
15161529

15171530
return super().formfield_for_foreignkey(db_field, request, **kwargs)
15181531

1532+
def get_readonly_fields(self, request, obj=None):
1533+
"""Only a user from the current Dataspace can edit Dataspace related FKs."""
1534+
readonly_fields = super().get_readonly_fields(request, obj)
1535+
1536+
if obj and obj.dataspace != request.user.dataspace:
1537+
readonly_fields += ("homepage_layout",)
1538+
1539+
return readonly_fields
1540+
15191541
def user_change_password(self, request, id, form_url=""):
15201542
"""
15211543
Remove the possibility to force a new password on a User.

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_admin.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,35 @@ def test_dataspace_admin_changeform_update_packages_from_scan_field_validation(s
188188
response = self.client.post(url, data, follow=True)
189189
self.assertContains(response, "was changed successfully.")
190190

191+
def test_dataspace_admin_changeform_hide_dataspace_fk_on_addition(self):
192+
self.client.login(username=self.super_user.username, password="secret")
193+
194+
expected1 = "homepage_layout"
195+
expected2 = "Homepage layout"
196+
197+
url = reverse("admin:dje_dataspace_add")
198+
response = self.client.get(url)
199+
self.assertNotContains(response, expected1)
200+
self.assertNotContains(response, expected2)
201+
202+
url = reverse("admin:dje_dataspace_change", args=[self.other_dataspace.pk])
203+
response = self.client.get(url)
204+
self.assertContains(response, expected1)
205+
self.assertContains(response, expected2)
206+
207+
def test_dataspace_admin_changeform_dataspace_fk_read_only_when_other_dataspace(self):
208+
self.client.login(username=self.super_user.username, password="secret")
209+
210+
expected = "configuration-0-homepage_layout"
211+
212+
url = reverse("admin:dje_dataspace_change", args=[self.other_dataspace.pk])
213+
response = self.client.get(url)
214+
self.assertNotContains(response, expected)
215+
216+
url = reverse("admin:dje_dataspace_change", args=[self.dataspace.pk])
217+
response = self.client.get(url)
218+
self.assertContains(response, expected)
219+
191220
def test_dataspace_admin_changelist_missing_in_filter_availability(self):
192221
# MissingInFilter is only available to superusers
193222
url = reverse("admin:organization_owner_changelist")

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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,34 @@ def test_user_admin_changeform_submit_row_delete_button_label(self):
413413
)
414414
self.assertContains(response, expected, html=True)
415415

416+
def test_user_admin_hide_dataspace_fk_on_addition(self):
417+
self.client.login(username="nexb_user", password="secret")
418+
url = reverse("admin:dje_dejacodeuser_add")
419+
response = self.client.get(url)
420+
421+
expected1 = "homepage_layout"
422+
expected2 = "Homepage layout"
423+
self.assertNotContains(response, expected1)
424+
self.assertNotContains(response, expected2)
425+
426+
url = reverse("admin:dje_dejacodeuser_change", args=[self.other_user.pk])
427+
response = self.client.get(url)
428+
self.assertContains(response, expected1)
429+
self.assertContains(response, expected2)
430+
431+
def test_user_admin_dataspace_fk_read_only_when_other_dataspace(self):
432+
self.client.login(username=self.nexb_user.username, password="secret")
433+
434+
expected = "id_homepage_layout"
435+
436+
url = reverse("admin:dje_dejacodeuser_change", args=[self.other_user.pk])
437+
response = self.client.get(url)
438+
self.assertNotContains(response, expected)
439+
440+
url = reverse("admin:dje_dejacodeuser_change", args=[self.nexb_user.pk])
441+
response = self.client.get(url)
442+
self.assertContains(response, expected)
443+
416444
def test_user_admin_form_scope_homepage_layout_choices(self):
417445
self.client.login(username=self.nexb_user.username, password="secret")
418446
url = reverse("admin:dje_dejacodeuser_change", args=[self.nexb_user.pk])
@@ -627,6 +655,7 @@ def test_user_password_reset_flow(self):
627655
class DejaCodeUserModelTestCase(TestCase):
628656
def setUp(self):
629657
self.dataspace = Dataspace.objects.create(name="nexB")
658+
self.alternate_dataspace = Dataspace.objects.create(name="alternate")
630659

631660
def test_user_model_queryset_manager(self):
632661
active = create_user("active", self.dataspace)
@@ -725,3 +754,18 @@ def test_user_model_serialize_user_data(self):
725754
"dataspace": "nexB",
726755
}
727756
self.assertEqual(expected, user.serialize_user_data())
757+
758+
def test_user_model_foreign_key_validation(self):
759+
layout_dataspace = create("CardLayout", self.dataspace)
760+
layout_alternate = create("CardLayout", self.alternate_dataspace)
761+
762+
expected_message = 'has Dataspace "alternate", expected "nexB"'
763+
with self.assertRaisesMessage(ValueError, expected_message):
764+
create_user("active", self.dataspace, homepage_layout=layout_alternate)
765+
766+
user = create_user("active", self.dataspace, homepage_layout=layout_dataspace)
767+
self.assertTrue(user.id)
768+
769+
with self.assertRaisesMessage(ValueError, expected_message):
770+
user.homepage_layout = layout_alternate
771+
user.save()

0 commit comments

Comments
 (0)