Skip to content

Commit b752d30

Browse files
committed
prevent non-superusers from setting is_staff on a user
1 parent 3547ece commit b752d30

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,12 @@ def validate(self, data):
613613
msg = "Only superusers are allowed to add or edit superusers."
614614
raise ValidationError(msg)
615615

616+
instance_is_staff = self.instance.is_staff if self.instance is not None else False
617+
data_is_staff = data.get("is_staff", instance_is_staff)
618+
if not self.context["request"].user.is_superuser and data_is_staff != instance_is_staff:
619+
msg = "Only superusers are allowed to add or edit staff users."
620+
raise ValidationError(msg)
621+
616622
if self.context["request"].method in {"PATCH", "PUT"} and "password" in data:
617623
msg = "Update of password though API is not allowed"
618624
raise ValidationError(msg)

unittests/test_apiv2_user.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.contrib.auth.models import Permission
12
from django.urls import reverse
23
from django.utils import timezone
34
from rest_framework.authtoken.models import Token
@@ -201,6 +202,118 @@ def test_user_reset_api_token_denies_non_privileged(self):
201202
r = nonpriv_client.post(url)
202203
self.assertEqual(r.status_code, 403, r.content[:1000])
203204

205+
def test_non_superuser_cannot_set_is_staff_via_api(self):
206+
"""
207+
A delegated user-manager (auth.change_user) must not be able to
208+
flip is_staff on themselves or anyone else — is_staff is a
209+
superuser-only flag under the legacy OS auth model, and granting
210+
it via API would let a non-superuser pivot into Django admin /
211+
full RBAC bypass.
212+
"""
213+
password = "testTEST1234!@#$"
214+
r = self.client.post(reverse("user-list"), {
215+
"username": "api-user-mgr",
216+
"email": "admin@dojo.com",
217+
"password": password,
218+
}, format="json")
219+
self.assertEqual(r.status_code, 201, r.content[:1000])
220+
mgr = User.objects.get(username="api-user-mgr")
221+
mgr.user_permissions.add(
222+
Permission.objects.get(codename="change_user"),
223+
Permission.objects.get(codename="add_user"),
224+
)
225+
226+
token_resp = self.client.post(reverse("api-token-auth"), {
227+
"username": "api-user-mgr",
228+
"password": password,
229+
}, format="json")
230+
self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000])
231+
mgr_client = APIClient()
232+
mgr_client.credentials(HTTP_AUTHORIZATION="Token " + token_resp.json()["token"])
233+
234+
# Self-escalation: setting is_staff on own account must be rejected.
235+
r = mgr_client.patch("{}{}/".format(reverse("user-list"), mgr.id), {
236+
"is_staff": True,
237+
}, format="json")
238+
self.assertEqual(r.status_code, 400, r.content[:1000])
239+
self.assertIn(
240+
"Only superusers are allowed to add or edit staff users.",
241+
r.content.decode("utf-8"),
242+
)
243+
mgr.refresh_from_db()
244+
self.assertFalse(mgr.is_staff)
245+
246+
# Target-escalation: setting is_staff on another user must be rejected.
247+
r = self.client.post(reverse("user-list"), {
248+
"username": "api-user-target",
249+
"email": "admin@dojo.com",
250+
"password": password,
251+
}, format="json")
252+
self.assertEqual(r.status_code, 201, r.content[:1000])
253+
target_id = r.json()["id"]
254+
255+
r = mgr_client.patch("{}{}/".format(reverse("user-list"), target_id), {
256+
"is_staff": True,
257+
}, format="json")
258+
self.assertEqual(r.status_code, 400, r.content[:1000])
259+
target = User.objects.get(id=target_id)
260+
self.assertFalse(target.is_staff)
261+
262+
# Create-time escalation must also be rejected.
263+
r = mgr_client.post(reverse("user-list"), {
264+
"username": "api-user-staff-on-create",
265+
"email": "admin@dojo.com",
266+
"password": password,
267+
"is_staff": True,
268+
}, format="json")
269+
self.assertEqual(r.status_code, 400, r.content[:1000])
270+
self.assertFalse(User.objects.filter(username="api-user-staff-on-create").exists())
271+
272+
def test_non_superuser_can_patch_self_without_touching_is_staff(self):
273+
"""
274+
Negative control for the is_staff guard: a delegated user-manager
275+
can still PATCH non-privileged fields on their own account; the
276+
new check only fires when is_staff actually changes.
277+
"""
278+
password = "testTEST1234!@#$"
279+
r = self.client.post(reverse("user-list"), {
280+
"username": "api-user-mgr2",
281+
"email": "admin@dojo.com",
282+
"password": password,
283+
}, format="json")
284+
self.assertEqual(r.status_code, 201, r.content[:1000])
285+
mgr = User.objects.get(username="api-user-mgr2")
286+
mgr.user_permissions.add(Permission.objects.get(codename="change_user"))
287+
288+
token_resp = self.client.post(reverse("api-token-auth"), {
289+
"username": "api-user-mgr2",
290+
"password": password,
291+
}, format="json")
292+
self.assertEqual(token_resp.status_code, 200, token_resp.content[:1000])
293+
mgr_client = APIClient()
294+
mgr_client.credentials(HTTP_AUTHORIZATION="Token " + token_resp.json()["token"])
295+
296+
r = mgr_client.patch("{}{}/".format(reverse("user-list"), mgr.id), {
297+
"first_name": "Renamed",
298+
}, format="json")
299+
self.assertEqual(r.status_code, 200, r.content[:1000])
300+
301+
def test_superuser_can_set_is_staff_via_api(self):
302+
"""Positive control: a superuser is still allowed to toggle is_staff."""
303+
r = self.client.post(reverse("user-list"), {
304+
"username": "api-user-promotable",
305+
"email": "admin@dojo.com",
306+
"password": "testTEST1234!@#$",
307+
}, format="json")
308+
self.assertEqual(r.status_code, 201, r.content[:1000])
309+
user_id = r.json()["id"]
310+
311+
r = self.client.patch("{}{}/".format(reverse("user-list"), user_id), {
312+
"is_staff": True,
313+
}, format="json")
314+
self.assertEqual(r.status_code, 200, r.content[:1000])
315+
self.assertTrue(User.objects.get(id=user_id).is_staff)
316+
204317
def test_user_reset_api_token_denies_global_owner_legacy(self):
205318
"""
206319
Legacy: Global_Role(role=Owner) is inert. Resetting another

0 commit comments

Comments
 (0)