Skip to content

Commit 248fa3b

Browse files
committed
[feature] Made RegisteredUser model support multi-tenancy #692
- Updated the RegisteredUser model to support organization-specific records. - Changed the primary key to UUID and added organization as a nullable ForeignKey. - Modified related code across the application to handle multiple registered users per organization. - Updated tests to reflect changes in the RegisteredUser model and ensure proper functionality. - Added migration scripts to handle the transition from the old model to the new schema. Closes #692
1 parent b512f3a commit 248fa3b

29 files changed

Lines changed: 840 additions & 143 deletions

openwisp_radius/admin.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django import forms
44
from django.conf import settings
55
from django.contrib import admin, messages
6-
from django.contrib.admin import ModelAdmin, StackedInline
6+
from django.contrib.admin import ModelAdmin, StackedInline, TabularInline
77
from django.contrib.admin.utils import model_ngettext
88
from django.contrib.auth import get_user_model
99
from django.core.exceptions import PermissionDenied
@@ -534,11 +534,15 @@ def has_change_permission(self, request, obj=None):
534534
return False
535535

536536

537-
class RegisteredUserInline(StackedInline):
537+
class RegisteredUserInline(TabularInline):
538538
model = RegisteredUser
539539
form = AlwaysHasChangedForm
540540
extra = 0
541-
readonly_fields = ("modified",)
541+
readonly_fields = (
542+
"organization",
543+
"modified",
544+
)
545+
fields = ("organization", "method", "is_verified", "modified")
542546

543547
def has_delete_permission(self, request, obj=None):
544548
return False
@@ -549,12 +553,17 @@ def has_delete_permission(self, request, obj=None):
549553
RadiusUserGroupInline,
550554
PhoneTokenInline,
551555
]
552-
UserAdmin.list_filter += (RegisteredUserFilter, "registered_user__method")
556+
UserAdmin.list_filter += (RegisteredUserFilter, "registered_users__method")
553557

554558

555559
def get_is_verified(self, obj):
556560
try:
557-
value = "yes" if obj.registered_user.is_verified else "no"
561+
if not obj.registered_users.exists():
562+
value = "unknown"
563+
elif obj.registered_users.filter(is_verified=True).exists():
564+
value = "yes"
565+
else:
566+
value = "no"
558567
except Exception:
559568
value = "unknown"
560569
icon_url = static(f"admin/img/icon-{value}.svg")
@@ -564,7 +573,6 @@ def get_is_verified(self, obj):
564573
UserAdmin.get_is_verified = get_is_verified
565574
UserAdmin.get_is_verified.short_description = _("Verified")
566575
UserAdmin.list_display.insert(3, "get_is_verified")
567-
UserAdmin.list_select_related = ("registered_user",)
568576

569577

570578
class OrganizationRadiusSettingsInline(admin.StackedInline):

openwisp_radius/api/freeradius_views.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def get_user(self, request, username, password):
290290
"""
291291
conditions = self._get_user_query_conditions(request)
292292
try:
293-
user = auth_backend.get_users(username).filter(conditions)[0]
293+
user = auth_backend.get_users(username).filter(conditions).distinct()[0]
294294
except IndexError:
295295
return None
296296
# ensure user is member of the authenticated org
@@ -409,8 +409,11 @@ def _get_user_query_conditions(self, request):
409409
# just ensure user is active
410410
if not needs_verification:
411411
return is_active
412-
# if identity verification is enabled
413-
is_verified = Q(registered_user__is_verified=True)
412+
organization_id = request._auth
413+
org_or_global = Q(registered_users__organization_id=organization_id) | Q(
414+
registered_users__organization__isnull=True
415+
)
416+
is_verified = Q(registered_users__is_verified=True) & org_or_global
414417
AUTHORIZE_UNVERIFIED = registration.AUTHORIZE_UNVERIFIED
415418
# and no method should authorize unverified users
416419
# ensure user is active AND verified
@@ -420,7 +423,9 @@ def _get_user_query_conditions(self, request):
420423
# ensure user is active AND
421424
# (user is verified OR user uses one of these methods)
422425
else:
423-
authorize_unverified = Q(registered_user__method__in=AUTHORIZE_UNVERIFIED)
426+
authorize_unverified = (
427+
Q(registered_users__method__in=AUTHORIZE_UNVERIFIED) & org_or_global
428+
)
424429
return is_active & (is_verified | authorize_unverified)
425430

426431
def authenticate_user(self, request, user, password):

openwisp_radius/api/serializers.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -688,9 +688,11 @@ def save(self, request):
688688
# the custom_signup method contains the openwisp specific logic
689689
self.custom_signup(request, user)
690690
# create a RegisteredUser object for every user that registers through API
691-
RegisteredUser.objects.create(
691+
org = self.context["view"].organization
692+
RegisteredUser.objects.get_or_create(
692693
user=user,
693-
method=self.validated_data["method"],
694+
organization=org,
695+
defaults={"method": self.validated_data["method"]},
694696
)
695697
setup_user_email(request, user, [])
696698
return user
@@ -753,20 +755,23 @@ def save(self):
753755
# yet, tha will be done by the phone token validation view
754756
# once the phone number has been validated
755757
# at this point we flag the user as unverified again
756-
self.user.registered_user.is_verified = False
757-
self.user.registered_user.save()
758+
org = self.context["view"].organization
759+
reg_user, _ = RegisteredUser.get_or_create_for_user_and_org(
760+
user=self.user,
761+
organization=org,
762+
defaults={"is_verified": False, "method": ""},
763+
)
764+
reg_user.is_verified = False
765+
reg_user.save()
758766

759767

760768
class RadiusUserSerializer(serializers.ModelSerializer):
761769
"""
762770
Used to return information about the logged in user
763771
"""
764772

765-
is_verified = serializers.BooleanField(source="registered_user.is_verified")
766-
method = serializers.CharField(
767-
source="registered_user.method",
768-
allow_null=True,
769-
)
773+
is_verified = serializers.SerializerMethodField()
774+
method = serializers.SerializerMethodField()
770775
password_expired = serializers.BooleanField(source="has_password_expired")
771776
radius_user_token = serializers.CharField(source="radius_token.key", default=None)
772777

@@ -786,3 +791,24 @@ class Meta:
786791
"password_expired",
787792
"radius_user_token",
788793
]
794+
795+
def _get_registered_user(self, obj):
796+
view = self.context.get("view")
797+
organization = getattr(view, "organization", None)
798+
org_reg_user = None
799+
global_reg_user = None
800+
for ru in obj.registered_users.all():
801+
if organization and ru.organization_id == organization.pk:
802+
org_reg_user = ru
803+
break
804+
elif ru.organization_id is None:
805+
global_reg_user = ru
806+
return org_reg_user or global_reg_user
807+
808+
def get_is_verified(self, obj):
809+
reg_user = self._get_registered_user(obj)
810+
return reg_user.is_verified if reg_user else None
811+
812+
def get_method(self, obj):
813+
reg_user = self._get_registered_user(obj)
814+
return reg_user.method if reg_user else None

openwisp_radius/api/utils.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,16 @@ def _needs_identity_verification(self, organization_filter_kwargs={}, org=None):
3030
except ObjectDoesNotExist:
3131
return app_settings.NEEDS_IDENTITY_VERIFICATION
3232

33-
def is_identity_verified_strong(self, user):
34-
try:
35-
return user.registered_user.is_identity_verified_strong
36-
except ObjectDoesNotExist:
33+
def is_identity_verified_strong(self, user, organization=None):
34+
reg_user = None
35+
global_reg_user = None
36+
for ru in user.registered_users.all():
37+
if organization and ru.organization_id == organization.pk:
38+
reg_user = ru
39+
break
40+
elif ru.organization_id is None:
41+
global_reg_user = ru
42+
reg_user = reg_user or global_reg_user
43+
if reg_user is None:
3744
return False
45+
return reg_user.is_identity_verified_strong

openwisp_radius/api/views.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
Organization = swapper.load_model("openwisp_users", "Organization")
9393
OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser")
9494
PhoneToken = load_model("PhoneToken")
95+
RegisteredUser = load_model("RegisteredUser")
9596
RadiusAccounting = load_model("RadiusAccounting")
9697
RadiusToken = load_model("RadiusToken")
9798
RadiusBatch = load_model("RadiusBatch")
@@ -321,7 +322,7 @@ def post(self, request, *args, **kwargs):
321322
# If identity verification is required, check if user is verified
322323
if self._needs_identity_verification(
323324
{"slug": kwargs["slug"]}
324-
) and not self.is_identity_verified_strong(user):
325+
) and not self.is_identity_verified_strong(user, self.organization):
325326
status_code = 401
326327
return Response(response, status=status_code)
327328

@@ -337,7 +338,7 @@ def validate_membership(self, user):
337338
):
338339
if self._needs_identity_verification(
339340
org=self.organization
340-
) and not self.is_identity_verified_strong(user):
341+
) and not self.is_identity_verified_strong(user, self.organization):
341342
raise PermissionDenied
342343
try:
343344
org_user = OrganizationUser(
@@ -383,9 +384,15 @@ def post(self, request, *args, **kwargs):
383384
response = {"response_code": "BLANK_OR_INVALID_TOKEN"}
384385
if request_token:
385386
try:
386-
token = UserToken.objects.select_related(
387-
"user", "user__registered_user"
388-
).get(key=request_token)
387+
token = (
388+
UserToken.objects.select_related(
389+
"user",
390+
)
391+
.prefetch_related(
392+
"user__registered_users",
393+
)
394+
.get(key=request_token)
395+
)
389396
except UserToken.DoesNotExist:
390397
pass
391398
else:
@@ -395,7 +402,7 @@ def post(self, request, *args, **kwargs):
395402
)
396403
# user may be in the process of changing the phone number
397404
# in that case show the new phone number (which is not verified yet)
398-
if not self.is_identity_verified_strong(user):
405+
if not self.is_identity_verified_strong(user, self.organization):
399406
phone_token = (
400407
PhoneToken.objects.filter(user=user)
401408
.order_by("-created")
@@ -753,8 +760,13 @@ def post(self, request, *args, **kwargs):
753760
if not is_valid:
754761
return self._error_response(_("Invalid code."))
755762
else:
756-
user.registered_user.is_verified = True
757-
user.registered_user.method = "mobile_phone"
763+
reg_user, _ = RegisteredUser.get_or_create_for_user_and_org(
764+
user=user,
765+
organization=self.organization,
766+
defaults={"is_verified": False, "method": ""},
767+
)
768+
reg_user.is_verified = True
769+
reg_user.method = "mobile_phone"
758770
user.is_active = True
759771
# Update username if phone_number is used as username
760772
if user.username == user.phone_number:
@@ -763,7 +775,7 @@ def post(self, request, *args, **kwargs):
763775
# we can write it to the user field
764776
user.phone_number = phone_token.phone_number
765777
user.save()
766-
user.registered_user.save()
778+
reg_user.save()
767779
# delete any radius token cache key if present
768780
cache.delete(f"rt-{phone_token.phone_number}")
769781
return Response(None, status=200)

openwisp_radius/base/admin_filters.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ def lookups(self, request, model_admin):
1515

1616
def queryset(self, request, queryset):
1717
if self.value() == "unknown":
18-
return queryset.filter(registered_user__isnull=True)
18+
return queryset.filter(registered_users__isnull=True)
1919
elif self.value():
20-
return queryset.filter(registered_user__is_verified=self.value() == "true")
20+
return queryset.filter(
21+
registered_users__is_verified=self.value() == "true"
22+
).distinct()
2123
return queryset

openwisp_radius/base/models.py

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import string
7+
import uuid
78
from datetime import timedelta
89
from io import StringIO
910

@@ -1058,7 +1059,11 @@ def save_user(self, user):
10581059
OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser")
10591060
RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser")
10601061
user.save()
1061-
registered_user = RegisteredUser(user=user, method="manual")
1062+
registered_user = RegisteredUser(
1063+
user=user,
1064+
method="manual",
1065+
organization=self.organization,
1066+
)
10621067
if self.organization.radius_settings.needs_identity_verification:
10631068
registered_user.is_verified = True
10641069
registered_user.save()
@@ -1570,14 +1575,12 @@ def is_valid(self, token):
15701575
return self.verified
15711576

15721577
def _validate_already_verified(self):
1573-
try:
1574-
if self.user.registered_user.is_verified:
1575-
logger.warning(f"User {self.user.pk} is already verified")
1576-
raise exceptions.UserAlreadyVerified(
1577-
_("This user has been already verified.")
1578-
)
1579-
except ObjectDoesNotExist:
1580-
pass
1578+
RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser")
1579+
if RegisteredUser.objects.filter(user=self.user, is_verified=True).exists():
1580+
logger.warning(f"User {self.user.pk} is already verified")
1581+
raise exceptions.UserAlreadyVerified(
1582+
_("This user has been already verified.")
1583+
)
15811584

15821585
def __check(self, token):
15831586
self._validate_already_verified()
@@ -1602,12 +1605,23 @@ def __check(self, token):
16021605
return token == self.token
16031606

16041607

1605-
class AbstractRegisteredUser(models.Model):
1606-
user = models.OneToOneField(
1608+
class AbstractRegisteredUser(UUIDModel):
1609+
user = models.ForeignKey(
16071610
settings.AUTH_USER_MODEL,
16081611
on_delete=models.CASCADE,
1609-
related_name="registered_user",
1610-
primary_key=True,
1612+
related_name="registered_users",
1613+
)
1614+
organization = models.ForeignKey(
1615+
swapper.get_model_name("openwisp_users", "Organization"),
1616+
on_delete=models.CASCADE,
1617+
null=True,
1618+
blank=True,
1619+
related_name="registered_users",
1620+
verbose_name=_("organization"),
1621+
help_text=(
1622+
"The organization this registration info belongs to. "
1623+
"If null, applies to all orgs without specific requirements."
1624+
),
16111625
)
16121626
method = models.CharField(
16131627
_("registration method"),
@@ -1649,6 +1663,54 @@ class Meta:
16491663
abstract = True
16501664
verbose_name = _("Registration Information")
16511665
verbose_name_plural = verbose_name
1666+
constraints = [
1667+
models.UniqueConstraint(
1668+
fields=["user", "organization"],
1669+
name="unique_registered_user_per_org",
1670+
),
1671+
models.UniqueConstraint(
1672+
fields=["user"],
1673+
condition=Q(organization__isnull=True),
1674+
name="unique_global_registered_user",
1675+
),
1676+
]
1677+
1678+
def clean(self):
1679+
super().clean()
1680+
Model = self._meta.model
1681+
qs = Model.objects.filter(user=self.user, organization=self.organization)
1682+
if self.pk:
1683+
qs = qs.exclude(pk=self.pk)
1684+
if qs.exists():
1685+
raise ValidationError(
1686+
_("A registration record already exists for this user/organization.")
1687+
)
1688+
1689+
@classmethod
1690+
def get_for_user_and_org(cls, user, organization):
1691+
try:
1692+
return cls.objects.get(user=user, organization=organization)
1693+
except cls.DoesNotExist:
1694+
return None
1695+
1696+
@classmethod
1697+
def get_or_create_for_user_and_org(cls, user, organization, defaults=None):
1698+
defaults = defaults or {}
1699+
return cls.objects.get_or_create(
1700+
user=user, organization=organization, defaults=defaults
1701+
)
1702+
1703+
@classmethod
1704+
def get_global_or_org_specific(cls, user, organization=None):
1705+
if organization:
1706+
try:
1707+
return cls.objects.get(user=user, organization=organization)
1708+
except cls.DoesNotExist:
1709+
pass
1710+
try:
1711+
return cls.objects.get(user=user, organization__isnull=True)
1712+
except cls.DoesNotExist:
1713+
return None
16521714

16531715
@classmethod
16541716
def unverify_inactive_users(cls):

0 commit comments

Comments
 (0)