Skip to content

Commit f591b4e

Browse files
authored
[change] Notification preferences: respected Django permissions #312
Ensured only users with permission to change notification settings are allowed to change the preferences of other users in the organizations they manage. Closes #312
1 parent 8b83769 commit f591b4e

10 files changed

Lines changed: 578 additions & 38 deletions

File tree

docs/user/notification-preferences.rst

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,14 @@ A notification setting can therefore either:
5252

5353
Notification settings are automatically generated for notification types
5454
and organizations associated with a user. Effective notification behavior
55-
is resolved dynamically using inherited defaults. Superusers have the
56-
ability to manage notification settings for all users, including adding or
57-
deleting them. Meanwhile, staff users can modify their preferred
58-
notification delivery methods, choosing between receiving notifications
59-
via web, email, or both. Additionally, users have the option to disable
60-
notifications entirely by turning off both web and email notification
61-
settings.
55+
is resolved dynamically using inherited defaults. Staff users who have the
56+
``change_notificationsetting`` permission can manage notification settings
57+
for users in organizations they manage. Superusers can manage any user's
58+
notification settings without additional permissions. Meanwhile, users can
59+
modify their own preferred notification delivery methods, choosing between
60+
receiving notifications via web, email, or both. Additionally, users have
61+
the option to disable notifications entirely by turning off both web and
62+
email notification settings.
6263

6364
.. note::
6465

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,71 @@
1-
from rest_framework.permissions import BasePermission
1+
from rest_framework.permissions import SAFE_METHODS, BasePermission
2+
3+
from openwisp_notifications.swapper import load_model, swapper_load_model
4+
5+
NotificationSetting = load_model("NotificationSetting")
6+
OrganizationUser = swapper_load_model("openwisp_users", "OrganizationUser")
27

38

49
class PreferencesPermission(BasePermission):
510
"""
611
Permission class for the notification preferences.
712
8-
Permission is granted only in these two cases:
13+
Permission is granted in these cases:
914
1. Superusers can change the notification preferences of any user.
10-
2. Regular users can only change their own preferences.
15+
2. Users with the required NotificationSetting permission can access
16+
the notification preferences of another user in a managed organization.
17+
3. All other authenticated users can only change their own preferences.
18+
This includes deprecated routes without a user_id URL parameter.
1119
"""
1220

21+
def _get_perm(self, action):
22+
"""Return the active NotificationSetting model permission."""
23+
return (
24+
f"{NotificationSetting._meta.app_label}."
25+
f"{action}_{NotificationSetting._meta.model_name}"
26+
)
27+
28+
def _has_required_perm(self, request):
29+
"""
30+
Match Django model permission semantics for preferences API methods.
31+
32+
Read requests accept view or change permission because users with
33+
change permission should be able to inspect settings before editing.
34+
Write requests require change permission.
35+
"""
36+
if request.method in SAFE_METHODS:
37+
return request.user.has_perm(
38+
self._get_perm("view")
39+
) or request.user.has_perm(self._get_perm("change"))
40+
return request.user.has_perm(self._get_perm("change"))
41+
42+
def _can_access_target_user(self, request, view):
43+
"""
44+
Check whether the requested user belongs to a managed organization.
45+
46+
This is used only for other-user access. Own preferences and
47+
superuser access are handled separately in ``has_permission``.
48+
"""
49+
user_id = view.kwargs.get("user_id")
50+
queryset = OrganizationUser.objects.filter(
51+
user_id=user_id,
52+
organization_id__in=request.user.organizations_managed,
53+
)
54+
organization_id = view.kwargs.get("organization_id")
55+
# Bulk organization updates must target an organization the requester
56+
# manages and the target user belongs to.
57+
if organization_id is not None:
58+
queryset = queryset.filter(organization_id=organization_id)
59+
return queryset.exists()
60+
1361
def has_permission(self, request, view):
14-
return request.user.is_superuser or request.user.id == view.kwargs.get(
15-
"user_id"
62+
if request.user.is_superuser:
63+
return True
64+
user_id = view.kwargs.get("user_id")
65+
if user_id is None:
66+
return True
67+
if str(request.user.id) == str(user_id):
68+
return True
69+
return self._has_required_perm(request) and self._can_access_target_user(
70+
request, view
1671
)

openwisp_notifications/api/views.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,24 @@ def get_queryset(self):
125125
if getattr(self, "swagger_fake_view", False):
126126
return NotificationSetting.objects.none() # pragma: no cover
127127
user_id = self.kwargs.get("user_id", self.request.user.id)
128-
return (
128+
queryset = (
129129
NotificationSetting.objects.exclude(
130130
Q(organization__is_active=False)
131131
| Q(type__in=app_settings.DISALLOW_PREFERENCES_CHANGE_TYPE)
132132
)
133133
.filter(user_id=user_id, deleted=False)
134134
.select_related("organization__notification_settings")
135135
)
136+
# Other-user reads should never expose organization settings outside
137+
# the organizations managed by the requester.
138+
if not self.request.user.is_superuser and str(self.request.user.pk) != str(
139+
user_id
140+
):
141+
queryset = queryset.filter(
142+
Q(organization__isnull=True)
143+
| Q(organization_id__in=self.request.user.organizations_managed)
144+
)
145+
return queryset
136146

137147

138148
class NotificationSettingListView(BaseNotificationSettingView, ListModelMixin):

openwisp_notifications/templates/admin/openwisp_users/user/change_form_object_tools.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
{% extends "admin/change_form_object_tools.html" %}
22

3-
{% load i18n admin_urls %}
3+
{% load i18n admin_urls notification_tags %}
44

55
{% block object-tools-items %}
6-
{% if request.user.is_staff and original.is_staff %}
6+
{% has_notification_setting_permission request.user original as has_perm %}
7+
{% if original.is_staff and has_perm %}
78
<li>
8-
<a class="button" href="{% url 'notifications:user_notification_preference' object_id %}">Notification Preferences</a>
9+
<a class="button" href="{% url 'notifications:user_notification_preference' object_id %}">{% trans "Notification Preferences" %}</a>
910
</li>
1011
{% endif %}
1112
{{ block.super }}

openwisp_notifications/templatetags/notification_tags.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from django.template import Library
44
from django.utils.html import format_html
55

6-
from openwisp_notifications.swapper import load_model
6+
from openwisp_notifications.swapper import load_model, swapper_load_model
77
from openwisp_notifications.utils import normalize_unread_count
88

99
Notification = load_model("Notification")
10+
OrganizationUser = swapper_load_model("openwisp_users", "OrganizationUser")
1011

1112
register = Library()
1213

@@ -30,8 +31,7 @@ def unread_notifications(context):
3031
count = get_notifications_count(context)
3132
output = ""
3233
if count:
33-
output = '<span id="ow-notification-count">{0}</span>'
34-
output = format_html(output.format(count))
34+
output = format_html('<span id="ow-notification-count">{0}</span>', count)
3535
return output
3636

3737

@@ -44,4 +44,20 @@ def should_load_notifications_widget(request):
4444
)
4545

4646

47+
@register.simple_tag
48+
def has_notification_setting_permission(user, target_user=None):
49+
if not user or not user.is_authenticated:
50+
return False
51+
NotificationSetting = load_model("NotificationSetting")
52+
perm = f"{NotificationSetting._meta.app_label}.change_{NotificationSetting._meta.model_name}"
53+
if not user.has_perm(perm):
54+
return False
55+
if user.is_superuser or target_user is None:
56+
return True
57+
return OrganizationUser.objects.filter(
58+
user=target_user,
59+
organization_id__in=user.organizations_managed,
60+
).exists()
61+
62+
4763
register.simple_tag(takes_context=True)(unread_notifications)

openwisp_notifications/tests/test_admin.py

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,20 @@
1212
from openwisp_notifications import settings as app_settings
1313
from openwisp_notifications.signals import notify
1414
from openwisp_notifications.swapper import load_model, swapper_load_model
15+
from openwisp_notifications.templatetags.notification_tags import (
16+
has_notification_setting_permission,
17+
)
1518
from openwisp_notifications.widgets import _add_object_notification_widget
1619
from openwisp_users.admin import UserAdmin
1720
from openwisp_users.tests.utils import TestMultitenantAdminMixin
1821

19-
from .test_helpers import MessagingRequest
22+
from .test_helpers import MessagingRequest, get_notification_setting_permission
2023

2124
Notification = load_model("Notification")
2225
NotificationSetting = load_model("NotificationSetting")
2326
notification_queryset = Notification.objects.order_by("-timestamp")
2427
Group = swapper_load_model("openwisp_users", "Group")
28+
OrganizationUser = swapper_load_model("openwisp_users", "OrganizationUser")
2529

2630

2731
class MockUser:
@@ -164,7 +168,9 @@ def test_ignore_notification_widget_add_view(self):
164168
self.assertNotContains(response, "owIsChangeForm")
165169

166170
def test_notification_preferences_button_staff_user(self):
167-
user = self._create_user(is_staff=True)
171+
user = self._create_user(
172+
username="btn_tester", email="btn_tester@test.com", is_staff=True
173+
)
168174
user_admin_page = reverse(
169175
f"admin:{self.users_app_label}_user_change", args=(user.pk,)
170176
)
@@ -180,14 +186,124 @@ def test_notification_preferences_button_staff_user(self):
180186
response = self.client.get(user_admin_page)
181187
self.assertContains(response, expected_html, html=True)
182188

183-
# Button does not appear for non-staff user
184-
with self.subTest("Button should not appear for non-staff user"):
189+
with self.subTest("Superuser does not see button for non-staff user"):
185190
user.is_staff = False
186191
user.full_clean()
187192
user.save()
188193
response = self.client.get(user_admin_page)
189194
self.assertNotContains(response, expected_html, html=True)
190195

196+
def test_notification_preferences_button_with_permission(self):
197+
perm = get_notification_setting_permission("change")
198+
org = self._get_org()
199+
staff_perm = self._create_administrator(
200+
username="perm_viewer",
201+
email="perm_viewer@test.com",
202+
organizations=[org],
203+
)
204+
staff_perm.user_permissions.add(perm)
205+
target_staff = self._create_user(
206+
username="staff_target_with_perm",
207+
email="staff_target_with_perm@test.com",
208+
is_staff=True,
209+
)
210+
OrganizationUser.objects.create(
211+
user=target_staff, organization=org, is_admin=False
212+
)
213+
target_staff_page = reverse(
214+
f"admin:{self.users_app_label}_user_change", args=(target_staff.pk,)
215+
)
216+
expected_url = reverse(
217+
"notifications:user_notification_preference", args=(target_staff.pk,)
218+
)
219+
expected_html = (
220+
f'<a class="button" href="{expected_url}">Notification Preferences</a>'
221+
)
222+
self.client.force_login(staff_perm)
223+
response = self.client.get(target_staff_page)
224+
self.assertEqual(response.status_code, 200)
225+
self.assertContains(response, expected_html, html=True)
226+
227+
non_staff = self._create_user(
228+
username="non_staff_target",
229+
email="non_staff_target@test.com",
230+
is_staff=False,
231+
)
232+
OrganizationUser.objects.create(
233+
user=non_staff, organization=org, is_admin=False
234+
)
235+
non_staff_page = reverse(
236+
f"admin:{self.users_app_label}_user_change", args=(non_staff.pk,)
237+
)
238+
expected_url = reverse(
239+
"notifications:user_notification_preference", args=(non_staff.pk,)
240+
)
241+
expected_html = (
242+
f'<a class="button" href="{expected_url}">Notification Preferences</a>'
243+
)
244+
response = self.client.get(non_staff_page)
245+
self.assertEqual(response.status_code, 200)
246+
self.assertNotContains(response, expected_html, html=True)
247+
248+
def test_notification_preferences_button_without_permission(self):
249+
org = self._get_org()
250+
staff_noperm = self._create_administrator(
251+
username="no_perm_viewer",
252+
email="no_perm_viewer@test.com",
253+
organizations=[org],
254+
)
255+
perm = get_notification_setting_permission("change")
256+
staff_noperm.user_permissions.remove(perm)
257+
for group in staff_noperm.groups.all():
258+
group.permissions.remove(perm)
259+
target_staff = self._create_user(
260+
username="staff_target",
261+
email="staff_target@test.com",
262+
is_staff=True,
263+
)
264+
OrganizationUser.objects.create(
265+
user=target_staff, organization=org, is_admin=False
266+
)
267+
target_staff_page = reverse(
268+
f"admin:{self.users_app_label}_user_change", args=(target_staff.pk,)
269+
)
270+
expected_url = reverse(
271+
"notifications:user_notification_preference", args=(target_staff.pk,)
272+
)
273+
expected_html = (
274+
f'<a class="button" href="{expected_url}">Notification Preferences</a>'
275+
)
276+
self.client.force_login(staff_noperm)
277+
response = self.client.get(target_staff_page)
278+
self.assertEqual(response.status_code, 200)
279+
self.assertNotContains(response, expected_html, html=True)
280+
281+
def test_notification_preferences_button_outside_managed_org(self):
282+
org_b = self._create_org(name="other org", slug="other-org")
283+
perm = get_notification_setting_permission("change")
284+
requester = self._create_administrator(
285+
username="requester",
286+
email="requester@test.com",
287+
organizations=[self._get_org()],
288+
)
289+
requester.user_permissions.add(perm)
290+
target_outside = self._create_user(
291+
username="outside_target",
292+
email="outside_target@test.com",
293+
is_staff=True,
294+
)
295+
OrganizationUser.objects.create(
296+
user=target_outside, organization=org_b, is_admin=False
297+
)
298+
299+
with self.subTest("Tag returns False for target outside managed orgs"):
300+
self.assertFalse(
301+
has_notification_setting_permission(requester, target_outside)
302+
)
303+
304+
with self.subTest("Tag returns True without target user"):
305+
self.assertTrue(has_notification_setting_permission(requester))
306+
191307

192308
class TestOrganizationNotificationsSettingsAdmin(BaseTestAdmin):
193309
app_label = "openwisp_notifications"

0 commit comments

Comments
 (0)