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


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