diff --git a/docs/user/basic-concepts.rst b/docs/user/basic-concepts.rst index 69ac29f7..2a123427 100644 --- a/docs/user/basic-concepts.rst +++ b/docs/user/basic-concepts.rst @@ -157,6 +157,8 @@ different roles in each. Here's a summary of the default organization roles. +.. _users_organization_manager: + Organization Manager ~~~~~~~~~~~~~~~~~~~~ @@ -241,6 +243,18 @@ objects are defined and managed by super administrators and can include configurations, policies, or other data that need to be consistent across all organizations. +Shared objects can only be created by superusers. Non-superusers (e.g. +:ref:`users_organization_manager`) have view-only access to shared +objects, both through the admin interface and the REST API. However, they +can use these shared objects when creating related organization-specific +resources. For example, an organization manager can use a shared VPN +server to create a configuration template for their organization. + +In some cases, non-superusers may be restricted from viewing sensitive +details of shared objects—particularly if such information could allow +them to gain unauthorized access to infrastructure or data used by other +organizations. + By sharing common resources, global uniformity and consistency can be enforced across the entire system. diff --git a/openwisp_users/api/mixins.py b/openwisp_users/api/mixins.py index 0eb32756..dc194a95 100644 --- a/openwisp_users/api/mixins.py +++ b/openwisp_users/api/mixins.py @@ -31,7 +31,8 @@ def queryset_organization_conditions(self): # If user has access to any organization, then include shared # objects in the queryset. if len(organizations): - conditions |= Q(**{f"{self.org_field}__isnull": True}) + lookup_field = self.organization_lookup.replace("__in", "__isnull") + conditions |= Q(**{lookup_field: True}) return conditions @@ -114,9 +115,16 @@ def assert_parent_exists(self): except (AssertionError, ValidationError): raise NotFound() + @property + def queryset_organization_conditions(self): + return Q( + **{self.organization_lookup: getattr(self.request.user, self._user_attr)} + ) + def get_organization_queryset(self, qs): - lookup = {self.organization_lookup: getattr(self.request.user, self._user_attr)} - return qs.filter(**lookup) + if self.request.user.is_anonymous: + return + return qs.filter(self.queryset_organization_conditions) def get_parent_queryset(self): raise NotImplementedError() @@ -130,7 +138,7 @@ class FilterByParentMembership(FilterByParent): _user_attr = "organizations_dict" -class FilterByParentManaged(FilterByParent): +class FilterByParentManaged(SharedObjectsLookup, FilterByParent): """ Filter queryset based on parent organizations managed by user """ @@ -138,7 +146,7 @@ class FilterByParentManaged(FilterByParent): _user_attr = "organizations_managed" -class FilterByParentOwned(FilterByParent): +class FilterByParentOwned(SharedObjectsLookup, FilterByParent): """ Filter queryset based on parent organizations owned by user """ @@ -146,6 +154,52 @@ class FilterByParentOwned(FilterByParent): _user_attr = "organizations_owned" +class HideSensitiveFieldsMixin: + """ + Mixin to hide sensitive fields in the serializer representation + based on the organization of the user. + """ + + def get_sensitive_fields(self): + """ + Returns a list of sensitive fields that should be hidden. + """ + ModelClass = self.Meta.model + return getattr(ModelClass, "sensitive_fields", []) + + def _is_object_shared(self, instance): + """ + Returns the organization of the instance if it exists. + """ + view = self.context.get("view") + organization_field = getattr(view, "organization_field", "organization_id") + related_field = instance + for field in organization_field.split("__"): + if hasattr(related_field, field): + related_field = getattr(related_field, field) + else: + return False + return related_field is None + + def hide_sensitive_fields(self, obj): + request = self.context.get("request") + if ( + request + and not request.user.is_superuser + and "organization" in obj + and obj["organization"] is None + ): + for field in self.get_sensitive_fields(): + if field in obj: + del obj[field] + return obj + + def to_representation(self, data): + rep = super().to_representation(data) + self.hide_sensitive_fields(rep) + return rep + + class FilterSerializerByOrganization(OrgLookup): """ Filter the options in browsable API for serializers @@ -190,7 +244,9 @@ def __init__(self, *args, **kwargs): self.filter_fields() -class FilterSerializerByOrgMembership(FilterSerializerByOrganization): +class FilterSerializerByOrgMembership( + HideSensitiveFieldsMixin, FilterSerializerByOrganization +): """ Filter serializer by organizations the user is member of """ @@ -198,7 +254,9 @@ class FilterSerializerByOrgMembership(FilterSerializerByOrganization): _user_attr = "organizations_dict" -class FilterSerializerByOrgManaged(FilterSerializerByOrganization): +class FilterSerializerByOrgManaged( + HideSensitiveFieldsMixin, FilterSerializerByOrganization +): """ Filter serializer by organizations managed by user """ @@ -206,7 +264,9 @@ class FilterSerializerByOrgManaged(FilterSerializerByOrganization): _user_attr = "organizations_managed" -class FilterSerializerByOrgOwned(FilterSerializerByOrganization): +class FilterSerializerByOrgOwned( + HideSensitiveFieldsMixin, FilterSerializerByOrganization +): """ Filter serializer by organizations owned by user """ diff --git a/openwisp_users/multitenancy.py b/openwisp_users/multitenancy.py index b6557fb2..e3926660 100644 --- a/openwisp_users/multitenancy.py +++ b/openwisp_users/multitenancy.py @@ -21,6 +21,9 @@ class MultitenantAdminMixin(object): multitenant_shared_relations = None multitenant_parent = None + def get_sensitive_fields(self, request, obj=None): + return getattr(self.model, "sensitive_fields", []) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) parent = self.multitenant_parent @@ -37,6 +40,29 @@ def get_repr(self, obj): get_repr.short_description = _("name") + def get_fields(self, request, obj=None): + """ + Return the list of fields to be displayed in the admin. + + If the user is not a superuser, it will remove sensitive fields. + """ + fields = super().get_fields(request, obj) + if obj and not request.user.is_superuser: + if self.multitenant_parent: + obj = getattr(obj, self.multitenant_parent) + if getattr(obj, "organization_id", None) is None: + sensitive_fields = self.get_sensitive_fields(request, obj) + return [f for f in fields if f not in sensitive_fields] + return fields + + @property + def org_field(self): + if hasattr(self.model, "organization"): + return "organization" + if self.multitenant_parent: + return f"{self.multitenant_parent}__organization" + return None + def get_queryset(self, request): """ If current user is not superuser, show only the @@ -48,15 +74,62 @@ def get_queryset(self, request): return self.multitenant_behaviour_for_user_admin(request) if user.is_superuser: return qs - if hasattr(self.model, "organization"): - return qs.filter(organization__in=user.organizations_managed) if self.model.__name__ == "Organization": return qs.filter(pk__in=user.organizations_managed) - elif not self.multitenant_parent: + if not self.org_field: + # if there is no organization field, return the queryset as is return qs - else: - qsarg = "{0}__organization__in".format(self.multitenant_parent) - return qs.filter(**{qsarg: user.organizations_managed}) + return qs.filter( + Q(**{f"{self.org_field}__in": user.organizations_managed}) + | Q(**{self.org_field: None}) + ) + + def get_search_results(self, request, queryset, search_term): + """ + Override to ensure that the search results are filtered by the + organization of the current user. + """ + if ( + request.GET.get("field_name") + and not request.user.is_superuser + and not self.multitenant_shared_relations + and self.org_field + ): + queryset = queryset.filter( + **{f"{self.org_field}__in": request.user.organizations_managed} + ) + return super().get_search_results(request, queryset, search_term) + + def _has_org_permission(self, request, obj, perm_func): + """ + Helper method to check object-level permissions for users + associated with specific organizations. + """ + perm = perm_func(request, obj) + if obj and self.multitenant_parent: + # In case of a multitenant parent, we need to check if the + # user has permission on the parent object. + obj = getattr(obj, self.multitenant_parent) + if not request.user.is_superuser and obj and hasattr(obj, "organization_id"): + perm = perm and ( + obj.organization_id + and str(obj.organization_id) in request.user.organizations_managed + ) + return perm + + def has_change_permission(self, request, obj=None): + """ + Returns True if the user has permission to change the object. + Non-superusers cannot change shared objects. + """ + return self._has_org_permission(request, obj, super().has_change_permission) + + def has_delete_permission(self, request, obj=None): + """ + Returns True if the user has permission to delete the object. + Non-superusers cannot change shared objects. + """ + return self._has_org_permission(request, obj, super().has_delete_permission) def _edit_form(self, request, form): """ @@ -149,7 +222,9 @@ class MultitenantOrgFilter(AutocompleteFilter): org_lookup = "id__in" title = _("organization") widget_attrs = AutocompleteFilter.widget_attrs.copy() - widget_attrs.update({"data-empty-label": SHARED_SYSTEMWIDE_LABEL}) + widget_attrs.update( + {"data-empty-label": SHARED_SYSTEMWIDE_LABEL, "data-is-filter": "true"} + ) class MultitenantRelatedOrgFilter(MultitenantOrgFilter): diff --git a/openwisp_users/static/admin/js/autocomplete.js b/openwisp_users/static/admin/js/autocomplete.js new file mode 100644 index 00000000..23ce9c23 --- /dev/null +++ b/openwisp_users/static/admin/js/autocomplete.js @@ -0,0 +1,39 @@ +// Custom override of Django's autocomplete.js to add the "is_filter" parameter +// to AJAX requests. This parameter enables the backend autocomplete view to +// determine if the request is for filtering, allowing it to include the shared +// option when appropriate (e.g., for filtering shared objects in the admin). + +"use strict"; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function () { + $.each(this, function (i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName, + is_filter: element.dataset.isFilter, + }; + }, + }, + }); + }); + return this; + }; + + $(function () { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $(".admin-autocomplete").not("[name*=__prefix__]").djangoAdminSelect2(); + }); + + document.addEventListener("formset:added", (event) => { + $(event.target).find(".admin-autocomplete").djangoAdminSelect2(); + }); +} diff --git a/openwisp_users/tests/test_api/__init__.py b/openwisp_users/tests/test_api/__init__.py index 2efa2ba5..54f5711a 100644 --- a/openwisp_users/tests/test_api/__init__.py +++ b/openwisp_users/tests/test_api/__init__.py @@ -12,5 +12,223 @@ def _obtain_auth_token(self, username="operator", password="tester"): return response.data["token"] -class APITestCase(TestMultitenantAdminMixin, AuthenticationMixin, TestCase): +class TestMultitenantApiMixin(TestMultitenantAdminMixin): + def _test_access_shared_object( + self, + token, + listview_name=None, + listview_path=None, + detailview_name=None, + detailview_path=None, + create_payload=None, + update_payload=None, + expected_count=0, + expected_status_codes=None, + ): + auth = dict(HTTP_AUTHORIZATION=f"Bearer {token}") + create_payload = create_payload or {} + update_payload = update_payload or {} + expected_status_codes = expected_status_codes or {} + + if listview_name or listview_path: + listview_path = listview_path or reverse(listview_name) + with self.subTest("HEAD and OPTION methods"): + response = self.client.head(listview_path, **auth) + self.assertEqual(response.status_code, expected_status_codes["head"]) + + response = self.client.options(listview_path, **auth) + self.assertEqual(response.status_code, expected_status_codes["option"]) + + with self.subTest("Create shared object"): + response = self.client.post( + listview_path, + data=create_payload, + content_type="application/json", + **auth, + ) + self.assertEqual(response.status_code, expected_status_codes["create"]) + if ( + expected_status_codes["create"] == 400 + and "organization" in create_payload + ): + self.assertEqual( + str(response.data["organization"][0]), + "This field may not be null.", + ) + + with self.subTest("List all shared objects"): + response = self.client.get(listview_path, **auth) + self.assertEqual(response.status_code, expected_status_codes["list"]) + data = response.data + if not isinstance(response.data, list): + data = data.get("results", data) + self.assertEqual(len(data), expected_count) + + if detailview_name or detailview_path: + if not detailview_path and listview_path and expected_count > 0: + detailview_path = reverse(detailview_name, args=[data[0]["id"]]) + + with self.subTest("Retrieve shared object"): + response = self.client.get(detailview_path, **auth) + self.assertEqual( + response.status_code, expected_status_codes["retrieve"] + ) + + with self.subTest("Update shared object"): + response = self.client.put( + detailview_path, + data=update_payload, + content_type="application/json", + **auth, + ) + self.assertEqual(response.status_code, expected_status_codes["update"]) + + with self.subTest("Delete shared object"): + response = self.client.delete(detailview_path, **auth) + self.assertEqual(response.status_code, expected_status_codes["delete"]) + + def _test_org_user_access_shared_object( + self, + listview_name=None, + listview_path=None, + detailview_name=None, + detailview_path=None, + create_payload=None, + update_payload=None, + expected_count=0, + expected_status_codes=None, + token=None, + ): + """ + Non-superusers can only view shared objects. + They cannot create, update, or delete them. + """ + if not token: + user = self._create_administrator(organizations=[self._get_org()]) + token = self._obtain_auth_token(user.username, "tester") + if not expected_status_codes: + expected_status_codes = { + "create": 400, + "list": 200, + "retrieve": 200, + "update": 403, + "delete": 403, + "head": 200, + "option": 200, + } + self._test_access_shared_object( + token=token, + listview_name=listview_name, + listview_path=listview_path, + detailview_name=detailview_name, + detailview_path=detailview_path, + create_payload=create_payload, + update_payload=update_payload, + expected_count=expected_count, + expected_status_codes=expected_status_codes, + ) + + def _test_superuser_access_shared_object( + self, + token, + listview_path=None, + listview_name=None, + detailview_path=None, + detailview_name=None, + create_payload=None, + update_payload=None, + expected_count=1, + expected_status_codes=None, + ): + """ + Superusers can perform all operations on shared objects. + """ + if not token: + user = self._get_admin() + token = self._obtain_auth_token(user.username, "tester") + if not expected_status_codes: + expected_status_codes = { + "create": 201, + "list": 200, + "retrieve": 200, + "update": 200, + "delete": 204, + "head": 200, + "option": 200, + } + self._test_access_shared_object( + token=token, + listview_name=listview_name, + listview_path=listview_path, + detailview_name=detailview_name, + detailview_path=detailview_path, + create_payload=create_payload, + update_payload=update_payload, + expected_count=expected_count, + expected_status_codes=expected_status_codes, + ) + + def _test_sensitive_fields_visibility_on_shared_and_org_objects( + self, + sensitive_fields, + shared_obj, + org_obj, + detailview_name, + listview_name, + organization, + org_admin=None, + super_user=None, + ): + def assert_sensitive_fields_visibility(obj, user, should_be_visible=False): + token = self._obtain_auth_token(user.username, "tester") + auth = {"HTTP_AUTHORIZATION": f"Bearer {token}"} + # List view + listview_path = reverse(listview_name) + response = self.client.get(listview_path, **auth) + self.assertEqual(response.status_code, 200) + results = ( + response.data + if "results" not in response.data + else response.data["results"] + ) + for item in results: + if str(item["id"]) == str(obj.pk): + break + for field in sensitive_fields: + self.assertEqual( + field in item, + should_be_visible, + ) + # Detail view + detailview_path = reverse(detailview_name, args=[obj.pk]) + response = self.client.get(detailview_path, **auth) + self.assertEqual(response.status_code, 200) + for field in sensitive_fields: + if should_be_visible: + self.assertIn(field, response.data) + else: + self.assertNotIn(field, response.data) + + org_admin = org_admin or self._create_administrator( + organizations=[organization] + ) + super_user = super_user or self._get_admin() + + with self.subTest("Org admin should not see sensitive fields in shared object"): + assert_sensitive_fields_visibility( + shared_obj, org_admin, should_be_visible=False + ) + + with self.subTest("Org admin should see sensitive fields in org object"): + assert_sensitive_fields_visibility( + org_obj, org_admin, should_be_visible=True + ) + + with self.subTest("Superuser should see sensitive fields in shared object"): + assert_sensitive_fields_visibility( + shared_obj, super_user, should_be_visible=True + ) + + +class APITestCase(TestMultitenantApiMixin, AuthenticationMixin, TestCase): pass diff --git a/openwisp_users/tests/utils.py b/openwisp_users/tests/utils.py index 6c8bbe27..1168794b 100644 --- a/openwisp_users/tests/utils.py +++ b/openwisp_users/tests/utils.py @@ -1,10 +1,13 @@ from datetime import date +import django from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.urls import reverse from swapper import load_model +from openwisp_users.multitenancy import SHARED_SYSTEMWIDE_LABEL + Organization = load_model("openwisp_users", "Organization") OrganizationOwner = load_model("openwisp_users", "OrganizationOwner") OrganizationUser = load_model("openwisp_users", "OrganizationUser") @@ -236,9 +239,124 @@ def _test_recoverlist_operator_403(self, app_label, model_label): ) self.assertEqual(response.status_code, 403) - def _get_autocomplete_view_path(self, app_label, model_name, field_name): + def _test_org_admin_create_shareable_object( + self, + path, + payload, + model, + expected_count=0, + user=None, + error_message=None, + raises_error=True, + ): + """ + Verifies a non-superuser cannot create a shareable object + """ + if not user: + user = self._create_administrator(organizations=[self._get_org()]) + self.client.force_login(user) + response = self.client.post( + path, + data=payload, + follow=True, + ) + if raises_error: + error_message = error_message or ( + '