Skip to content

Commit 850c40a

Browse files
authored
[feature] Made RegisteredUser model support multi-tenancy #692
Registered users can now join multiple organizations independently, using different identity verification methods and verification status for each organization. Previously, a user could only have a single global registration record, making these multi-organization use cases impossible. This update includes the required data model, migrations, and internal application changes to support organization-specific registered users. Breaking change: the CSV export format for "registered_users" has changed. Organization-specific registration data is now exported as a nested structure instead of the previous flat columns. External integrations relying on this CSV output will need to be updated. Closes #692
1 parent 6f48fb0 commit 850c40a

46 files changed

Lines changed: 4032 additions & 338 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/user/management_commands.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ Following is an example:
129129
130130
./manage.py delete_unverified_users --older-than-days 1 --exclude-methods mobile_phone,email
131131
132+
If a user has multiple ``RegisteredUser`` rows across organizations, the
133+
command keeps that user when **any** related row uses one of the excluded
134+
methods.
135+
132136
``upgrade_from_django_freeradius``
133137
----------------------------------
134138

docs/user/rest-api.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,48 @@ Param Description
803803
phone_number string
804804
============ ===========
805805

806+
Update user registration method
807+
+++++++++++++++++++++++++++++++
808+
809+
**Requires the user auth token (Bearer Token)**.
810+
811+
Allows users to update their registered user method for an organization.
812+
The method can only be updated when it is currently set to
813+
``pending_verification``. Once updated, it cannot be changed again via
814+
this endpoint.
815+
816+
This endpoint is used during cross-organization login when a user
817+
authenticates to a new organization. The user must complete verification
818+
for that organization before they can create account with the new
819+
organization.
820+
821+
.. code-block:: text
822+
823+
/api/v1/radius/organization/<organization-slug>/account/registration-method/
824+
825+
Responds only to **POST**.
826+
827+
Parameters:
828+
829+
====== ===========
830+
Param Description
831+
====== ===========
832+
method string (\*)
833+
====== ===========
834+
835+
(\*) ``method`` must be one of the available
836+
:ref:`registration/verification methods
837+
<openwisp_radius_needs_identity_verification>`, excluding
838+
``pending_verification``.
839+
840+
**Success Response (200 OK)**:
841+
842+
.. code-block:: json
843+
844+
{
845+
"method": "mobile_phone"
846+
}
847+
806848
.. _radius_batch_user_creation:
807849

808850
Batch user creation

docs/user/settings.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,9 @@ verification method. The following choices are available by default:
696696
- ``mobile_phone``: Mobile phone number :ref:`verification via SMS
697697
<openwisp_radius_sms_verification_enabled>`
698698
- ``social_login``: :doc:`social login feature <social_login>`
699+
- ``pending_verification``: Transitional state used when a user
700+
authenticates to a new organization but has not yet completed
701+
verification for that organization.
699702

700703
.. note::
701704

@@ -714,6 +717,33 @@ verification method. The following choices are available by default:
714717
**Disclaimer:** these are just suggestions on possible configurations
715718
of OpenWISP RADIUS and must not be considered as legal advice.
716719

720+
``OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS``
721+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
722+
723+
**Default**: ``["", "email", "mobile_phone"]``
724+
725+
Defines which ``RegisteredUser.method`` values can be written by users
726+
through the public registration APIs.
727+
728+
Methods not included in this setting cannot be selected by users through
729+
those APIs, even if they are present in the full list returned by
730+
``get_registration_choices()``.
731+
732+
This is especially useful to keep server-assigned provenance methods such
733+
as ``saml``, ``social_login`` or ``manual`` out of user-controlled API
734+
input. These methods may still be assigned internally by server-side
735+
authentication or integration flows when appropriate.
736+
737+
Example:
738+
739+
.. code-block:: python
740+
741+
OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS = [
742+
"",
743+
"email",
744+
"mobile_phone",
745+
]
746+
717747
.. _openwisp_radius_register_registration_method:
718748

719749
Adding support for more registration/verification methods

openwisp_radius/admin.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
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
10+
from django.db.models import Prefetch
11+
from django.forms.models import BaseInlineFormSet
1012
from django.http import HttpResponseRedirect
1113
from django.templatetags.static import static
1214
from django.urls import reverse
@@ -534,11 +536,31 @@ def has_change_permission(self, request, obj=None):
534536
return False
535537

536538

539+
class RegisteredUserFormset(BaseInlineFormSet):
540+
def get_unique_error_message(self, unique_check):
541+
# Django inline formsets perform their own uniqueness validation
542+
# (BaseModelFormSet.validate_unique) *before* model-level validation runs.
543+
# Because of this, the custom `violation_error_message` defined on
544+
# `UniqueConstraint` is never surfaced in the admin UI.
545+
#
546+
# Overriding this method allows us to replace Django’s generic
547+
# "Please correct the duplicate data for <field>." message with a
548+
# domain-specific, user-friendly error that matches our constraint.
549+
if unique_check == ("user", "organization"):
550+
return _(
551+
"A user cannot have more than one registration record in the"
552+
" same organization."
553+
)
554+
555+
537556
class RegisteredUserInline(StackedInline):
538557
model = RegisteredUser
539558
form = AlwaysHasChangedForm
559+
formset = RegisteredUserFormset
540560
extra = 0
541561
readonly_fields = ("modified",)
562+
fields = ("organization", "method", "is_verified", "modified")
563+
autocomplete_fields = ("organization",)
542564

543565
def has_delete_permission(self, request, obj=None):
544566
return False
@@ -549,22 +571,50 @@ def has_delete_permission(self, request, obj=None):
549571
RadiusUserGroupInline,
550572
PhoneTokenInline,
551573
]
552-
UserAdmin.list_filter += (RegisteredUserFilter, "registered_user__method")
574+
UserAdmin.list_filter += (RegisteredUserFilter, "registered_users__method")
575+
user_admin_get_queryset = UserAdmin.get_queryset
576+
577+
578+
def get_queryset(self, request):
579+
queryset = user_admin_get_queryset(self, request)
580+
registered_users = RegisteredUser.objects.only(
581+
"user_id", "organization_id", "is_verified"
582+
)
583+
if not request.user.is_superuser:
584+
registered_users = registered_users.filter(
585+
organization__in=request.user.organizations_managed
586+
)
587+
return queryset.prefetch_related(
588+
Prefetch(
589+
"registered_users",
590+
queryset=registered_users,
591+
to_attr="prefetched_registered_users",
592+
)
593+
)
553594

554595

555596
def get_is_verified(self, obj):
556-
try:
557-
value = "yes" if obj.registered_user.is_verified else "no"
558-
except Exception:
597+
prefetched_registered_users = getattr(obj, "prefetched_registered_users", None)
598+
if prefetched_registered_users is not None:
599+
is_verifieds = [
600+
reg_user.is_verified for reg_user in prefetched_registered_users
601+
]
602+
else:
603+
is_verifieds = []
604+
if not is_verifieds:
559605
value = "unknown"
606+
elif any(is_verifieds):
607+
value = "yes"
608+
else:
609+
value = "no"
560610
icon_url = static(f"admin/img/icon-{value}.svg")
561611
return mark_safe(f'<img src="{icon_url}" alt="{value}">')
562612

563613

614+
UserAdmin.get_queryset = get_queryset
564615
UserAdmin.get_is_verified = get_is_verified
565616
UserAdmin.get_is_verified.short_description = _("Verified")
566617
UserAdmin.list_display.insert(3, "get_is_verified")
567-
UserAdmin.list_select_related = ("registered_user",)
568618

569619

570620
class OrganizationRadiusSettingsInline(admin.StackedInline):

openwisp_radius/api/freeradius_views.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757

5858
RadiusToken = load_model("RadiusToken")
5959
RadiusAccounting = load_model("RadiusAccounting")
60+
RegisteredUser = load_model("RegisteredUser")
6061
OrganizationRadiusSettings = load_model("OrganizationRadiusSettings")
6162
OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser")
6263
Organization = swapper.load_model("openwisp_users", "Organization")
@@ -290,7 +291,7 @@ def get_user(self, request, username, password):
290291
"""
291292
conditions = self._get_user_query_conditions(request)
292293
try:
293-
user = auth_backend.get_users(username).filter(conditions)[0]
294+
user = auth_backend.get_users(username).filter(conditions).distinct()[0]
294295
except IndexError:
295296
return None
296297
# ensure user is member of the authenticated org
@@ -409,19 +410,21 @@ def _get_user_query_conditions(self, request):
409410
# just ensure user is active
410411
if not needs_verification:
411412
return is_active
412-
# if identity verification is enabled
413-
is_verified = Q(registered_user__is_verified=True)
413+
organization_id = request._auth
414+
registered_user = Q(registered_users__organization_id=organization_id)
415+
is_verified = Q(registered_users__is_verified=True)
414416
AUTHORIZE_UNVERIFIED = registration.AUTHORIZE_UNVERIFIED
415-
# and no method should authorize unverified users
416-
# ensure user is active AND verified
417417
if not AUTHORIZE_UNVERIFIED:
418-
return is_active & is_verified
418+
return is_active & registered_user & is_verified
419419
# in case some methods are allowed to authorize unverified users
420420
# ensure user is active AND
421421
# (user is verified OR user uses one of these methods)
422422
else:
423-
authorize_unverified = Q(registered_user__method__in=AUTHORIZE_UNVERIFIED)
424-
return is_active & (is_verified | authorize_unverified)
423+
return (
424+
is_active
425+
& registered_user
426+
& (is_verified | Q(registered_users__method__in=AUTHORIZE_UNVERIFIED))
427+
)
425428

426429
def authenticate_user(self, request, user, password):
427430
"""

openwisp_radius/api/serializers.py

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
from .. import settings as app_settings
3737
from ..base.forms import PasswordResetForm
3838
from ..counters.exceptions import SkipCheck
39-
from ..registration import REGISTRATION_METHOD_CHOICES
4039
from ..utils import (
4140
get_group_checks,
4241
get_organization_radius_settings,
@@ -571,9 +570,13 @@ class RegisterSerializer(
571570
'verification in its "Organization RADIUS Settings."'
572571
),
573572
default="",
574-
choices=REGISTRATION_METHOD_CHOICES,
573+
choices=(),
575574
)
576575

576+
def __init__(self, *args, **kwargs):
577+
super().__init__(*args, **kwargs)
578+
self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS
579+
577580
def validate_phone_number(self, phone_number):
578581
org = self.context["view"].organization
579582
if get_organization_radius_settings(org, "sms_verification"):
@@ -688,9 +691,11 @@ def save(self, request):
688691
# the custom_signup method contains the openwisp specific logic
689692
self.custom_signup(request, user)
690693
# create a RegisteredUser object for every user that registers through API
691-
RegisteredUser.objects.create(
694+
org = self.context["view"].organization
695+
RegisteredUser.get_or_create_for_user_and_org(
692696
user=user,
693-
method=self.validated_data["method"],
697+
organization=org,
698+
defaults={"method": self.validated_data["method"]},
694699
)
695700
setup_user_email(request, user, [])
696701
return user
@@ -753,20 +758,64 @@ def save(self):
753758
# yet, tha will be done by the phone token validation view
754759
# once the phone number has been validated
755760
# at this point we flag the user as unverified again
756-
self.user.registered_user.is_verified = False
757-
self.user.registered_user.save()
761+
org = self.context["view"].organization
762+
reg_user, _ = RegisteredUser.get_or_create_for_user_and_org(
763+
user=self.user,
764+
organization=org,
765+
defaults={"is_verified": False, "method": ""},
766+
)
767+
reg_user.is_verified = False
768+
reg_user.save()
769+
770+
771+
class UpdateRegisteredUserMethodSerializer(ValidatedModelSerializer):
772+
method = serializers.ChoiceField(
773+
choices=app_settings.USER_SETTABLE_REGISTRATION_METHODS,
774+
help_text=_(
775+
"The registration method to set for the user. "
776+
"Cannot be 'pending_verification'."
777+
),
778+
)
779+
780+
class Meta:
781+
model = RegisteredUser
782+
fields = ["method"]
783+
784+
def __init__(self, *args, **kwargs):
785+
super().__init__(*args, **kwargs)
786+
self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS
787+
788+
def validate_method(self, value):
789+
if value == "pending_verification":
790+
raise serializers.ValidationError(
791+
_("'pending_verification' cannot be set as a registration method.")
792+
)
793+
return value
794+
795+
def validate(self, attrs):
796+
if self.instance.method != "pending_verification":
797+
raise serializers.ValidationError(
798+
{
799+
"method": _(
800+
"Method can only be updated from pending verification state."
801+
)
802+
}
803+
)
804+
return attrs
805+
806+
def update(self, instance, validated_data):
807+
instance.method = validated_data["method"]
808+
instance.save()
809+
return instance
758810

759811

760812
class RadiusUserSerializer(serializers.ModelSerializer):
761813
"""
762814
Used to return information about the logged in user
763815
"""
764816

765-
is_verified = serializers.BooleanField(source="registered_user.is_verified")
766-
method = serializers.CharField(
767-
source="registered_user.method",
768-
allow_null=True,
769-
)
817+
is_verified = serializers.SerializerMethodField()
818+
method = serializers.SerializerMethodField()
770819
password_expired = serializers.BooleanField(source="has_password_expired")
771820
radius_user_token = serializers.CharField(source="radius_token.key", default=None)
772821

@@ -786,3 +835,30 @@ class Meta:
786835
"password_expired",
787836
"radius_user_token",
788837
]
838+
839+
def _get_registered_user(self, obj):
840+
if not hasattr(self, "_registered_user_cache"):
841+
self._registered_user_cache = {}
842+
if obj.pk not in self._registered_user_cache:
843+
view = self.context.get("view")
844+
organization = getattr(view, "organization", None)
845+
reg_user = None
846+
# We iterate over .all() instead of using .filter() because callers
847+
# of this serializer (e.g. validate_auth_token) prefetch
848+
# "registered_users" via prefetch_related. Using .all() hits the
849+
# in-memory prefetch cache (0 DB queries), whereas .filter() would
850+
# bypass the cache and issue a new query every time.
851+
for ru in obj.registered_users.all():
852+
if organization and ru.organization_id == organization.pk:
853+
reg_user = ru
854+
break
855+
self._registered_user_cache[obj.pk] = reg_user
856+
return self._registered_user_cache[obj.pk]
857+
858+
def get_is_verified(self, obj):
859+
reg_user = self._get_registered_user(obj)
860+
return reg_user.is_verified if reg_user else None
861+
862+
def get_method(self, obj):
863+
reg_user = self._get_registered_user(obj)
864+
return reg_user.method if reg_user else None

openwisp_radius/api/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def get_api_urls(api_views=None):
7777
api_views.change_phone_number,
7878
name="phone_number_change",
7979
),
80+
path(
81+
"radius/organization/<slug:slug>/account/registration-method/",
82+
api_views.update_registered_user_registration_method,
83+
name="update_registered_user_registration_method",
84+
),
8085
path("radius/batch/", api_views.batch, name="batch"),
8186
path(
8287
"radius/organization/<slug:slug>/batch/<uuid:pk>/pdf/",

0 commit comments

Comments
 (0)