Skip to content

Commit 7ba30e3

Browse files
committed
[admin/api] Make batch user creation organization field readonly #609
This change prevents modification of the organization field for existing RadiusBatch objects in both admin interface and REST API. When editing existing batch objects, the organization field is now readonly to maintain data integrity and prevent accidental organization changes. Changes: - Added organization field to readonly_fields in RadiusBatchAdmin for existing objects - Created RadiusBatchUpdateSerializer with organization as readonly field - Added BatchUpdateView for handling batch detail API operations - Added comprehensive tests for both admin and API readonly functionality Fixes #609
1 parent f93534d commit 7ba30e3

File tree

11 files changed

+240
-15
lines changed

11 files changed

+240
-15
lines changed

openwisp_radius/admin.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -463,18 +463,17 @@ def delete_selected_batches(self, request, queryset):
463463
)
464464

465465
def get_readonly_fields(self, request, obj=None):
466-
readonly_fields = super(RadiusBatchAdmin, self).get_readonly_fields(
467-
request, obj
468-
)
466+
readonly_fields = super().get_readonly_fields(request, obj)
469467
if obj:
470-
return (
468+
return readonly_fields + (
471469
"strategy",
470+
"organization",
472471
"prefix",
473472
"csvfile",
474473
"number_of_users",
475474
"users",
476475
"expiration_date",
477-
) + readonly_fields
476+
)
478477
return readonly_fields
479478

480479

openwisp_radius/api/serializers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,36 @@ class Meta:
438438
read_only_fields = ("created", "modified", "user_credentials")
439439

440440

441+
class RadiusBatchUpdateSerializer(RadiusBatchSerializer):
442+
"""
443+
Serializer for updating RadiusBatch objects.
444+
Makes the organization field readonly for existing objects.
445+
"""
446+
447+
def validate(self, data):
448+
"""
449+
Simplified validation for partial updates.
450+
Only validates essential fields based on strategy.
451+
"""
452+
strategy = data.get("strategy") or (self.instance and self.instance.strategy)
453+
454+
if (
455+
strategy == "prefix"
456+
and "number_of_users" in data
457+
and not data.get("number_of_users")
458+
):
459+
raise serializers.ValidationError(
460+
{"number_of_users": _("The field number_of_users cannot be empty")}
461+
)
462+
463+
return serializers.ModelSerializer.validate(self, data)
464+
465+
class Meta:
466+
model = RadiusBatch
467+
fields = "__all__"
468+
read_only_fields = ("created", "modified", "user_credentials", "organization")
469+
470+
441471
class PasswordResetSerializer(BasePasswordResetSerializer):
442472
input = serializers.CharField()
443473
email = None

openwisp_radius/api/urls.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ def get_api_urls(api_views=None):
7878
name="phone_number_change",
7979
),
8080
path("radius/batch/", api_views.batch, name="batch"),
81+
path(
82+
"radius/batch/<uuid:pk>/", api_views.batch_detail, name="batch_detail"
83+
),
8184
path(
8285
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",
8386
api_views.download_rad_batch_pdf,

openwisp_radius/api/views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
GenericAPIView,
3232
ListAPIView,
3333
RetrieveAPIView,
34+
RetrieveUpdateAPIView,
3435
)
3536
from rest_framework.permissions import (
3637
DjangoModelPermissions,
@@ -62,6 +63,7 @@
6263
ChangePhoneNumberSerializer,
6364
RadiusAccountingSerializer,
6465
RadiusBatchSerializer,
66+
RadiusBatchUpdateSerializer,
6567
UserRadiusUsageSerializer,
6668
ValidatePhoneTokenSerializer,
6769
)
@@ -144,6 +146,34 @@ def validate_membership(self, user):
144146
raise serializers.ValidationError({"non_field_errors": [message]})
145147

146148

149+
class BatchUpdateView(ThrottledAPIMixin, RetrieveUpdateAPIView):
150+
"""
151+
API view for updating existing RadiusBatch objects.
152+
Organization field is readonly for existing objects.
153+
"""
154+
155+
authentication_classes = (BearerAuthentication, SessionAuthentication)
156+
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)
157+
queryset = RadiusBatch.objects.none()
158+
serializer_class = RadiusBatchSerializer
159+
160+
def get_queryset(self):
161+
"""Filter batches by user's organization membership"""
162+
user = self.request.user
163+
if user.is_superuser:
164+
return RadiusBatch.objects.all()
165+
return RadiusBatch.objects.filter(organization__in=user.organizations_managed)
166+
167+
def get_serializer_class(self):
168+
"""Use RadiusBatchUpdateSerializer for PUT/PATCH requests"""
169+
if self.request.method in ("PUT", "PATCH"):
170+
return RadiusBatchUpdateSerializer
171+
return RadiusBatchSerializer
172+
173+
174+
batch_detail = BatchUpdateView.as_view()
175+
176+
147177
class DownloadRadiusBatchPdfView(ThrottledAPIMixin, DispatchOrgMixin, RetrieveAPIView):
148178
authentication_classes = (BearerAuthentication, SessionAuthentication)
149179
permission_classes = (IsOrganizationManager, IsAdminUser, DjangoModelPermissions)

openwisp_radius/integrations/monitoring/tasks.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ def _write_user_signup_metric_for_all(metric_key):
9595
except KeyError:
9696
total_registered_users[""] = users_without_registereduser
9797

98+
from openwisp_radius.registration import REGISTRATION_METHOD_CHOICES
99+
all_methods = [clean_registration_method(m) for m, _ in REGISTRATION_METHOD_CHOICES]
100+
for m in all_methods:
101+
if m not in [clean_registration_method(k) for k in total_registered_users.keys()]:
102+
total_registered_users[m] = 0
98103
for method, count in total_registered_users.items():
99104
method = clean_registration_method(method)
100105
metric = get_metric_func(organization_id="__all__", registration_method=method)

openwisp_radius/tests/test_admin.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,3 +1494,31 @@ def test_admin_menu_groups(self):
14941494
with self.subTest("test radius group is registered"):
14951495
html = '<div class="mg-dropdown-label">RADIUS </div>'
14961496
self.assertContains(response, html, html=True)
1497+
1498+
def test_radiusbatch_organization_readonly_for_existing_objects(self):
1499+
"""
1500+
Test that organization field is readonly for existing RadiusBatch objects
1501+
"""
1502+
batch = self._create_radius_batch(
1503+
name="test-batch", strategy="prefix", prefix="test-prefix"
1504+
)
1505+
url = reverse(f"admin:{self.app_label}_radiusbatch_change", args=[batch.pk])
1506+
1507+
response = self.client.get(url)
1508+
self.assertEqual(response.status_code, 200)
1509+
1510+
self.assertContains(response, "readonly")
1511+
self.assertContains(response, batch.organization.name)
1512+
1513+
def test_radiusbatch_organization_editable_for_new_objects(self):
1514+
"""
1515+
Test that organization field is editable for new RadiusBatch objects
1516+
"""
1517+
url = reverse(f"admin:{self.app_label}_radiusbatch_add")
1518+
1519+
response = self.client.get(url)
1520+
self.assertEqual(response.status_code, 200)
1521+
1522+
self.assertContains(response, 'name="organization"')
1523+
form_html = response.content.decode()
1524+
self.assertIn('name="organization"', form_html)

openwisp_radius/tests/test_api/test_api.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,111 @@ def test_user_group_check_serializer_counter_does_not_exist(self):
10531053
},
10541054
)
10551055

1056+
def _get_admin_auth_header(self):
1057+
"""Helper method to get admin authentication header"""
1058+
login_payload = {"username": "admin", "password": "tester"}
1059+
login_url = reverse("radius:user_auth_token", args=[self.default_org.slug])
1060+
login_response = self.client.post(login_url, data=login_payload)
1061+
return f"Bearer {login_response.json()['key']}"
1062+
1063+
def test_batch_update_organization_readonly(self):
1064+
"""
1065+
Test that organization field is readonly when updating RadiusBatch objects
1066+
"""
1067+
data = self._radius_batch_prefix_data()
1068+
response = self._radius_batch_post_request(data)
1069+
self.assertEqual(response.status_code, 201)
1070+
batch = RadiusBatch.objects.get()
1071+
original_org = batch.organization
1072+
1073+
new_org = self._create_org(**{"name": "new-org", "slug": "new-org"})
1074+
1075+
header = self._get_admin_auth_header()
1076+
1077+
url = reverse("radius:batch_detail", args=[batch.pk])
1078+
update_data = {
1079+
"name": "updated-batch-name",
1080+
"organization": str(new_org.pk),
1081+
}
1082+
response = self.client.patch(
1083+
url,
1084+
json.dumps(update_data),
1085+
HTTP_AUTHORIZATION=header,
1086+
content_type="application/json",
1087+
)
1088+
self.assertEqual(response.status_code, 200)
1089+
1090+
batch.refresh_from_db()
1091+
self.assertEqual(batch.organization, original_org)
1092+
self.assertEqual(batch.name, "updated-batch-name")
1093+
1094+
def test_batch_retrieve_and_update_api(self):
1095+
"""
1096+
Test retrieving and updating RadiusBatch objects via API
1097+
"""
1098+
data = self._radius_batch_prefix_data()
1099+
response = self._radius_batch_post_request(data)
1100+
self.assertEqual(response.status_code, 201)
1101+
batch = RadiusBatch.objects.get()
1102+
1103+
header = self._get_admin_auth_header()
1104+
1105+
url = reverse("radius:batch_detail", args=[batch.pk])
1106+
response = self.client.get(url, HTTP_AUTHORIZATION=header)
1107+
self.assertEqual(response.status_code, 200)
1108+
self.assertEqual(response.data["name"], batch.name)
1109+
self.assertEqual(str(response.data["organization"]), str(batch.organization.pk))
1110+
1111+
update_data = {
1112+
"name": "updated-batch-name",
1113+
"strategy": "prefix",
1114+
"prefix": batch.prefix,
1115+
"organization_slug": batch.organization.slug,
1116+
}
1117+
response = self.client.put(
1118+
url,
1119+
json.dumps(update_data),
1120+
HTTP_AUTHORIZATION=header,
1121+
content_type="application/json",
1122+
)
1123+
self.assertEqual(response.status_code, 200)
1124+
batch.refresh_from_db()
1125+
self.assertEqual(batch.name, "updated-batch-name")
1126+
1127+
def test_batch_update_permissions(self):
1128+
"""
1129+
Test that proper permissions are required for updating RadiusBatch objects
1130+
"""
1131+
data = self._radius_batch_prefix_data()
1132+
response = self._radius_batch_post_request(data)
1133+
self.assertEqual(response.status_code, 201)
1134+
batch = RadiusBatch.objects.get()
1135+
1136+
url = reverse("radius:batch_detail", args=[batch.pk])
1137+
1138+
response = self.client.patch(url, {"name": "new-name"})
1139+
self.assertEqual(response.status_code, 401)
1140+
1141+
user = self._get_user()
1142+
user_token = Token.objects.create(user=user)
1143+
header = f"Bearer {user_token.key}"
1144+
response = self.client.patch(
1145+
url,
1146+
json.dumps({"name": "new-name"}),
1147+
HTTP_AUTHORIZATION=header,
1148+
content_type="application/json",
1149+
)
1150+
self.assertEqual(response.status_code, 403)
1151+
1152+
header = self._get_admin_auth_header()
1153+
response = self.client.patch(
1154+
url,
1155+
json.dumps({"name": "new-name"}),
1156+
HTTP_AUTHORIZATION=header,
1157+
content_type="application/json",
1158+
)
1159+
self.assertEqual(response.status_code, 200)
1160+
10561161

10571162
class TestTransactionApi(AcctMixin, ApiTokenMixin, BaseTransactionTestCase):
10581163
def test_user_radius_usage_view(self):

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ multi_line_output = 3
2020
use_parentheses = true
2121
include_trailing_comma = true
2222
force_grid_wrap = 0
23+

tests/openwisp2/sample_radius/api/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from openwisp_radius.api.freeradius_views import AccountingView as BaseAccountingView
22
from openwisp_radius.api.freeradius_views import AuthorizeView as BaseAuthorizeView
33
from openwisp_radius.api.freeradius_views import PostAuthView as BasePostAuthView
4+
from openwisp_radius.api.views import BatchUpdateView as BaseBatchUpdateView
45
from openwisp_radius.api.views import BatchView as BaseBatchView
56
from openwisp_radius.api.views import ChangePhoneNumberView as BaseChangePhoneNumberView
67
from openwisp_radius.api.views import CreatePhoneTokenView as BaseCreatePhoneTokenView
@@ -98,6 +99,10 @@ class RadiusAccountingView(BaseRadiusAccountingView):
9899
pass
99100

100101

102+
class BatchUpdateView(BaseBatchUpdateView):
103+
pass
104+
105+
101106
authorize = AuthorizeView.as_view()
102107
postauth = PostAuthView.as_view()
103108
accounting = AccountingView.as_view()
@@ -116,3 +121,4 @@ class RadiusAccountingView(BaseRadiusAccountingView):
116121
change_phone_number = ChangePhoneNumberView.as_view()
117122
download_rad_batch_pdf = DownloadRadiusBatchPdfView.as_view()
118123
radius_accounting = RadiusAccountingView.as_view()
124+
batch_detail = BatchUpdateView.as_view()

tests/openwisp2/sample_radius/migrations/0021_organizationradiussettings_registration_enabled.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ class Migration(migrations.Migration):
1616
field=models.BooleanField(
1717
blank=True,
1818
default=True,
19-
help_text="Whether the registration API endpoint should be enabled or "
20-
"not",
19+
help_text=(
20+
"Whether the registration API endpoint should be enabled or not"
21+
),
2122
null=True,
2223
),
2324
),
@@ -27,7 +28,9 @@ class Migration(migrations.Migration):
2728
field=models.BooleanField(
2829
blank=True,
2930
default=None,
30-
help_text="Whether the registration using SAML should be enabled or not",
31+
help_text=(
32+
"Whether the registration using SAML should be enabled or not"
33+
),
3134
null=True,
3235
verbose_name=_("SAML registration enabled"),
3336
),
@@ -38,7 +41,10 @@ class Migration(migrations.Migration):
3841
field=models.BooleanField(
3942
blank=True,
4043
default=None,
41-
help_text="Whether the registration using social applications should be enabled or not",
44+
help_text=(
45+
"Whether the registration using social applications should be "
46+
"enabled or not"
47+
),
4248
null=True,
4349
),
4450
),

0 commit comments

Comments
 (0)