Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/user/basic-concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ different roles in each.

Here's a summary of the default organization roles.

.. _users_organization_manager:

Organization Manager
~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -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.

Expand Down
76 changes: 68 additions & 8 deletions openwisp_users/api/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand All @@ -130,22 +138,68 @@ class FilterByParentMembership(FilterByParent):
_user_attr = "organizations_dict"


class FilterByParentManaged(FilterByParent):
class FilterByParentManaged(SharedObjectsLookup, FilterByParent):
"""
Filter queryset based on parent organizations managed by user
"""

_user_attr = "organizations_managed"


class FilterByParentOwned(FilterByParent):
class FilterByParentOwned(SharedObjectsLookup, FilterByParent):
"""
Filter queryset based on parent organizations owned by user
"""

_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
Comment on lines +157 to +200
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's understand the repository structure
git ls-files | grep -E "(serializer|mixin)" | head -20

Repository: openwisp/openwisp-users

Length of output: 190


🏁 Script executed:

# Find the actual file mentioned in the review
find . -type f -name "mixins.py" | grep -E "api|openwisp_users"

Repository: openwisp/openwisp-users

Length of output: 124


🏁 Script executed:

# Check for serializer classes and their organization field handling
rg "class.*Serializer.*:" --type py -A 20 | head -100

Repository: openwisp/openwisp-users

Length of output: 5836


🏁 Script executed:

# Find where HideSensitiveFieldsMixin is used
rg "HideSensitiveFieldsMixin" --type py -B 2 -A 5

Repository: openwisp/openwisp-users

Length of output: 1689


🏁 Script executed:

# Find models with sensitive_fields attribute
rg "sensitive_fields" --type py -B 2 -A 2

Repository: openwisp/openwisp-users

Length of output: 9122


🏁 Script executed:

# Look at the actual mixins.py file to understand context better
cat -n ./openwisp_users/api/mixins.py | head -250

Repository: openwisp/openwisp-users

Length of output: 9385


🏁 Script executed:

# Check if there are any views that override organization_field
rg "organization_field" --type py -B 2 -A 2

Repository: openwisp/openwisp-users

Length of output: 5937


🏁 Script executed:

# Look at actual serializers to see what fields they include
cat -n ./tests/testapp/serializers.py | head -100

Repository: openwisp/openwisp-users

Length of output: 3254


🏁 Script executed:

# Check the Template model and its serializer
rg "class Template" --type py -A 10 | head -50

Repository: openwisp/openwisp-users

Length of output: 2491


🏁 Script executed:

# Look for test cases that verify the sensitive fields behavior
cat -n ./openwisp_users/tests/test_api/__init__.py | sed -n '1,100p'

Repository: openwisp/openwisp-users

Length of output: 4727


🏁 Script executed:

# Find ShareableOrgMixin definition
rg "class ShareableOrgMixin" --type py -A 15

Repository: openwisp/openwisp-users

Length of output: 736


🏁 Script executed:

# Check what fields Template includes
rg "class Template" --type py -B 5 -A 20 | grep -A 20 "class Template"

Repository: openwisp/openwisp-users

Length of output: 4480


🏁 Script executed:

# Look at the full test for sensitive fields visibility
rg "_test_sensitive_fields_visibility_on_shared_and_org_objects" --type py -A 50 | head -80

Repository: openwisp/openwisp-users

Length of output: 5883


🏁 Script executed:

# Check if there's a specific test for nested organization_field with sensitive fields
rg "test_template_sensitive_fields" --type py -A 30

Repository: openwisp/openwisp-users

Length of output: 1590


🏁 Script executed:

# Check if _is_object_shared method is used anywhere
rg "_is_object_shared" --type py

Repository: openwisp/openwisp-users

Length of output: 138


Remove unused _is_object_shared method.

The implementation correctly hides sensitive fields for shared (organization=None) objects by directly checking the "organization" key in the serialized representation. However, the _is_object_shared method (lines 170–182) is defined but never called; hide_sensitive_fields doesn't use it. The method can be safely removed to eliminate dead code.

Regarding the nested organization field concern: while organization_field on views can reference nested paths like template__organization, this is used only for permission filtering on the model instance. The serialized output always includes "organization" at the top level from the ShareableOrgMixin, so the direct check in hide_sensitive_fields works correctly across all models.

🤖 Prompt for AI Agents
In `@openwisp_users/api/mixins.py` around lines 157 - 200, Remove the unused
helper _is_object_shared method from the HideSensitiveFieldsMixin class—it's
never called (hide_sensitive_fields checks the serialized "organization" key
directly) so delete the entire _is_object_shared definition and its docstring;
keep hide_sensitive_fields, get_sensitive_fields, and to_representation as-is,
and run tests/lint to ensure no external references to _is_object_shared remain
(check views or tests that might have referenced _is_object_shared by name
before removing).



class FilterSerializerByOrganization(OrgLookup):
"""
Filter the options in browsable API for serializers
Expand Down Expand Up @@ -190,23 +244,29 @@ def __init__(self, *args, **kwargs):
self.filter_fields()


class FilterSerializerByOrgMembership(FilterSerializerByOrganization):
class FilterSerializerByOrgMembership(
HideSensitiveFieldsMixin, FilterSerializerByOrganization
):
"""
Filter serializer by organizations the user is member of
"""

_user_attr = "organizations_dict"


class FilterSerializerByOrgManaged(FilterSerializerByOrganization):
class FilterSerializerByOrgManaged(
HideSensitiveFieldsMixin, FilterSerializerByOrganization
):
"""
Filter serializer by organizations managed by user
"""

_user_attr = "organizations_managed"


class FilterSerializerByOrgOwned(FilterSerializerByOrganization):
class FilterSerializerByOrgOwned(
HideSensitiveFieldsMixin, FilterSerializerByOrganization
):
"""
Filter serializer by organizations owned by user
"""
Expand Down
89 changes: 82 additions & 7 deletions openwisp_users/multitenancy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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):
Expand Down
39 changes: 39 additions & 0 deletions openwisp_users/static/admin/js/autocomplete.js
Original file line number Diff line number Diff line change
@@ -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();
});
}
Loading
Loading