diff --git a/users/admin.py b/users/admin.py index 911fa945..5135ebee 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,12 +1,14 @@ from datetime import datetime, timedelta +from django.contrib import admin from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin, UserAdmin as BaseUserAdmin from django.contrib.auth.models import Group -from django.contrib.gis import admin +from django.db.models import Exists, OuterRef from django.utils import timezone from django.utils.formats import localize from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ +from social_django.models import UserSocialAuth from traffic_control.admin.operational_area import GroupOperationalAreaInline from traffic_control.admin.responsible_entity import GroupResponsibleEntityInline @@ -14,8 +16,78 @@ from .models import User +class AuthenticationTypeFilter(admin.SimpleListFilter): + """ + Filter users by their authentication type (Local, Azure AD, Both, or None). + """ + + title = _("authentication type") + parameter_name = "auth_type" + + def lookups(self, request, model_admin): + """ + Define filter options. + + Args: + request: The current request object. + model_admin: The model admin instance. + + Returns: + list: List of tuples (value, label) for filter options. + """ + return [ + ("local", _("Local")), + ("oidc", _("Azure AD")), + ("both", _("Both")), + ("none", _("None")), + ] + + def queryset(self, request, queryset): + """ + Filter queryset based on selected authentication type. + + Args: + request: The current request object. + queryset: The queryset to filter. + + Returns: + QuerySet: Filtered queryset based on authentication type. + """ + value = self.value() + if not value: + return queryset + + # Annotate queryset with has_oidc field + has_social_auth = Exists(UserSocialAuth.objects.filter(user=OuterRef("pk"), provider="tunnistamo")) + queryset = queryset.annotate(has_oidc=has_social_auth) + + if value == "local": + # Password only: has usable password AND no social auth + return queryset.filter(has_oidc=False).exclude(password__startswith="!") + + if value == "oidc": + # Azure AD only: has social auth AND no usable password + return queryset.filter(has_oidc=True, password__startswith="!") + + if value == "both": + # Both: has social auth AND has usable password + return queryset.filter(has_oidc=True).exclude(password__startswith="!") + + if value == "none": + # Neither: no social auth AND no usable password + return queryset.filter(has_oidc=False, password__startswith="!") + + return queryset + + class UserAdmin(BaseUserAdmin): fieldsets = BaseUserAdmin.fieldsets + ( + ( + _("Authentication Type"), + { + "fields": ("auth_type_display",), + }, + ), (_("Additional user information"), {"fields": ("additional_information",)}), ( _("Operational area"), @@ -36,12 +108,14 @@ class UserAdmin(BaseUserAdmin): }, ), ) + readonly_fields = ("auth_type_display",) filter_horizontal = BaseUserAdmin.filter_horizontal + ( "operational_areas", "responsible_entities", ) list_display = ( "username", + "auth_type_display", "email", "first_name", "last_name", @@ -50,6 +124,36 @@ class UserAdmin(BaseUserAdmin): "is_staff", "is_superuser", ) + list_filter = BaseUserAdmin.list_filter + (AuthenticationTypeFilter,) + + def get_queryset(self, request): + """ + Optimize queryset by prefetching social_auth relationships. + + Args: + request: The current request object. + + Returns: + QuerySet: Optimized queryset with prefetched social_auth. + """ + queryset = super().get_queryset(request) + return queryset.prefetch_related("social_auth") + + @admin.display(description=_("Authentication Type")) + def auth_type_display(self, obj: User) -> str: + """ + Display authentication type with color coding. + + Args: + obj (User): The user instance. + + Returns: + str: HTML formatted authentication type with color. + """ + text, color = obj.get_auth_type() + if color: + return format_html('{}', color, text) + return text @admin.display(description=_("last login"), ordering="last_login") def last_login_highlighted(self, obj: User): diff --git a/users/models.py b/users/models.py index ac19b11d..de2e54ae 100644 --- a/users/models.py +++ b/users/models.py @@ -1,5 +1,5 @@ import uuid -from typing import Optional, TYPE_CHECKING +from typing import Optional, Tuple, TYPE_CHECKING from auditlog.registry import auditlog from django.contrib.auth.models import Group @@ -90,6 +90,46 @@ def has_responsible_entity_permission(self, responsible_entity: Optional["Respon .exists() ) + def is_oidc_user(self) -> bool: + """ + Check if user can authenticate via Azure AD (OIDC through Tunnistamo). + + Returns: + bool: True if user has a social auth association with Tunnistamo provider. + """ + return self.social_auth.filter(provider="tunnistamo").exists() + + def is_local_user(self) -> bool: + """ + Check if user can authenticate via local password. + + Returns: + bool: True if user has a usable password set. + """ + return self.has_usable_password() + + def get_auth_type(self) -> Tuple[str, Optional[str]]: + """ + Get user's authentication type with display color. + + Returns: + Tuple[str, Optional[str]]: Display text and color code. + - ("Local", "red") for password-only authentication + - ("Azure AD", "green") for OIDC-only authentication + - ("Both", "chocolate") for both authentication methods + - ("None", None) for no authentication method + """ + has_local = self.is_local_user() + has_oidc = self.is_oidc_user() + + if has_local and has_oidc: + return ("Both", "chocolate") + if has_local: + return ("Local", "red") + if has_oidc: + return ("Azure AD", "green") + return ("None", None) + class Meta: verbose_name = _("User") verbose_name_plural = _("Users") diff --git a/users/tests/test_user_admin.py b/users/tests/test_user_admin.py new file mode 100644 index 00000000..fad2d2ac --- /dev/null +++ b/users/tests/test_user_admin.py @@ -0,0 +1,249 @@ +import pytest +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory +from social_django.models import UserSocialAuth + +from traffic_control.tests.factories import get_user +from users.admin import AuthenticationTypeFilter, UserAdmin +from users.models import User + + +@pytest.mark.django_db +class TestAuthenticationTypeFilter: + """Tests for AuthenticationTypeFilter in user admin.""" + + def test_lookups(self): + """Test that filter provides correct lookup options.""" + filter_instance = AuthenticationTypeFilter(None, {}, User, UserAdmin) + lookups = filter_instance.lookups(None, None) + assert len(lookups) == 4 + lookup_values = [item[0] for item in lookups] + assert "local" in lookup_values + assert "oidc" in lookup_values + assert "both" in lookup_values + assert "none" in lookup_values + + def test_queryset_filter_local(self): + """Test filtering for local-only users.""" + # Create users with different auth types + user_local = get_user(username="local_user") + user_local.set_password("SecureP@ssw0rd123") + user_local.save() + + user_oidc = get_user(username="oidc_user") + user_oidc.set_unusable_password() + user_oidc.save() + UserSocialAuth.objects.create(user=user_oidc, provider="tunnistamo", uid="oidc-uid") + + user_both = get_user(username="both_user") + user_both.set_password("SecureP@ssw0rd456") + user_both.save() + UserSocialAuth.objects.create(user=user_both, provider="tunnistamo", uid="both-uid") + + # Test filter - need to create proper admin instance and request + factory = RequestFactory() + request = factory.get("/admin/users/user/", {"auth_type": "local"}) + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + # The params dict tracks which params are used by filters + params = request.GET.copy() + filter_instance = AuthenticationTypeFilter(request, params, User, user_admin) + # Only query the test users we created + test_users = User.objects.filter(username__in=["local_user", "oidc_user", "both_user"]) + queryset = filter_instance.queryset(request, test_users) + usernames = list(queryset.values_list("username", flat=True)) + + assert "local_user" in usernames + assert "oidc_user" not in usernames + assert "both_user" not in usernames + + def test_queryset_filter_oidc(self): + """Test filtering for OIDC-only users.""" + # Create users with different auth types + user_local = get_user(username="local_user") + user_local.set_password("SecureP@ssw0rd123") + user_local.save() + + user_oidc = get_user(username="oidc_user") + user_oidc.set_unusable_password() + user_oidc.save() + UserSocialAuth.objects.create(user=user_oidc, provider="tunnistamo", uid="oidc-uid") + + user_both = get_user(username="both_user") + user_both.set_password("SecureP@ssw0rd456") + user_both.save() + UserSocialAuth.objects.create(user=user_both, provider="tunnistamo", uid="both-uid") + + # Test filter - need to create proper admin instance and request + factory = RequestFactory() + request = factory.get("/admin/users/user/", {"auth_type": "oidc"}) + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + # The params dict tracks which params are used by filters + params = request.GET.copy() + filter_instance = AuthenticationTypeFilter(request, params, User, user_admin) + # Only query the test users we created + test_users = User.objects.filter(username__in=["local_user", "oidc_user", "both_user"]) + queryset = filter_instance.queryset(request, test_users) + usernames = list(queryset.values_list("username", flat=True)) + + assert "local_user" not in usernames + assert "oidc_user" in usernames + assert "both_user" not in usernames + + def test_queryset_filter_both(self): + """Test filtering for users with both auth methods.""" + # Create users with different auth types + user_local = get_user(username="local_user") + user_local.set_password("SecureP@ssw0rd123") + user_local.save() + + user_oidc = get_user(username="oidc_user") + user_oidc.set_unusable_password() + user_oidc.save() + UserSocialAuth.objects.create(user=user_oidc, provider="tunnistamo", uid="oidc-uid") + + user_both = get_user(username="both_user") + user_both.set_password("SecureP@ssw0rd456") + user_both.save() + UserSocialAuth.objects.create(user=user_both, provider="tunnistamo", uid="both-uid") + + # Test filter - need to create proper admin instance and request + factory = RequestFactory() + request = factory.get("/admin/users/user/", {"auth_type": "both"}) + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + # The params dict tracks which params are used by filters + params = request.GET.copy() + filter_instance = AuthenticationTypeFilter(request, params, User, user_admin) + # Only query the test users we created + test_users = User.objects.filter(username__in=["local_user", "oidc_user", "both_user"]) + queryset = filter_instance.queryset(request, test_users) + usernames = list(queryset.values_list("username", flat=True)) + + assert "local_user" not in usernames + assert "oidc_user" not in usernames + assert "both_user" in usernames + + def test_queryset_filter_none(self): + """Test filtering for users with no auth methods.""" + # Create users with different auth types + user_local = get_user(username="local_user") + user_local.set_password("SecureP@ssw0rd123") + user_local.save() + + user_none = get_user(username="none_user") + user_none.set_unusable_password() + user_none.save() + + # Test filter - need to create proper admin instance and request + factory = RequestFactory() + request = factory.get("/admin/users/user/", {"auth_type": "none"}) + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + # The params dict tracks which params are used by filters + params = request.GET.copy() + filter_instance = AuthenticationTypeFilter(request, params, User, user_admin) + # Only query the test users we created + test_users = User.objects.filter(username__in=["local_user", "none_user"]) + queryset = filter_instance.queryset(request, test_users) + usernames = list(queryset.values_list("username", flat=True)) + + assert "local_user" not in usernames + assert "none_user" in usernames + + +@pytest.mark.django_db +class TestUserAdmin: + """Tests for UserAdmin display and functionality.""" + + def test_auth_type_display_local(self): + """Test auth_type_display method for local-only user.""" + user = get_user() + user.set_password("SecureP@ssw0rd123") + user.save() + + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + display = user_admin.auth_type_display(user) + + assert "Local" in display + assert "red" in display + + def test_auth_type_display_oidc(self): + """Test auth_type_display method for OIDC-only user.""" + user = get_user() + user.set_unusable_password() + user.save() + UserSocialAuth.objects.create(user=user, provider="tunnistamo", uid="test-uid") + + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + display = user_admin.auth_type_display(user) + + assert "Azure AD" in display + assert "green" in display + + def test_auth_type_display_both(self): + """Test auth_type_display method for user with both auth methods.""" + user = get_user() + user.set_password("SecureP@ssw0rd123") + user.save() + UserSocialAuth.objects.create(user=user, provider="tunnistamo", uid="test-uid") + + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + display = user_admin.auth_type_display(user) + + assert "Both" in display + assert "chocolate" in display + + def test_auth_type_display_none(self): + """Test auth_type_display method for user with no auth methods.""" + user = get_user() + user.set_unusable_password() + user.save() + + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + display = user_admin.auth_type_display(user) + + assert "None" in display + + def test_get_queryset_prefetches_social_auth(self): + """Test that get_queryset prefetches social_auth for optimization.""" + factory = RequestFactory() + request = factory.get("/admin/users/user/") + request.user = get_user(admin=True) + + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + queryset = user_admin.get_queryset(request) + + # Check that prefetch_related was called + # _prefetch_related_lookups can contain strings or Prefetch objects + prefetch_lookups = queryset._prefetch_related_lookups + assert "social_auth" in prefetch_lookups or any( + getattr(p, "prefetch_to", None) == "social_auth" for p in prefetch_lookups + ) + + def test_auth_type_in_list_display(self): + """Test that auth_type_display is in list_display.""" + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + + assert "auth_type_display" in user_admin.list_display + + def test_auth_type_in_readonly_fields(self): + """Test that auth_type_display is in readonly_fields.""" + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + + assert "auth_type_display" in user_admin.readonly_fields + + def test_auth_filter_in_list_filter(self): + """Test that AuthenticationTypeFilter is in list_filter.""" + admin_site = AdminSite() + user_admin = UserAdmin(User, admin_site) + + assert AuthenticationTypeFilter in user_admin.list_filter diff --git a/users/tests/test_user_model.py b/users/tests/test_user_model.py index fb8375a6..399016af 100644 --- a/users/tests/test_user_model.py +++ b/users/tests/test_user_model.py @@ -94,3 +94,101 @@ def test__user_permissions_changed_to_auditlog(): permission = Permission.objects.get(codename="change_user") user.user_permissions.add(permission) assert LogEntry.objects.get_for_object(user).filter(action=LogEntry.Action.UPDATE).count() == 1 + + +# Authentication type tests + + +@pytest.mark.django_db +def test__user_is_local_user__with_password(): + """Test that user with usable password is identified as local user.""" + user = get_user() + user.set_password("SecureP@ssw0rd123") + user.save() + assert user.is_local_user() is True + + +@pytest.mark.django_db +def test__user_is_local_user__without_password(): + """Test that user without usable password is not identified as local user.""" + user = get_user() + user.set_unusable_password() + user.save() + assert user.is_local_user() is False + + +@pytest.mark.django_db +def test__user_is_oidc_user__with_social_auth(): + """Test that user with Tunnistamo social auth is identified as OIDC user.""" + from social_django.models import UserSocialAuth + + user = get_user() + UserSocialAuth.objects.create(user=user, provider="tunnistamo", uid="test-uid-123") + assert user.is_oidc_user() is True + + +@pytest.mark.django_db +def test__user_is_oidc_user__without_social_auth(): + """Test that user without social auth is not identified as OIDC user.""" + user = get_user() + assert user.is_oidc_user() is False + + +@pytest.mark.django_db +def test__user_is_oidc_user__with_different_provider(): + """Test that user with non-Tunnistamo social auth is not identified as OIDC user.""" + from social_django.models import UserSocialAuth + + user = get_user() + UserSocialAuth.objects.create(user=user, provider="other-provider", uid="test-uid-456") + assert user.is_oidc_user() is False + + +@pytest.mark.django_db +def test__user_get_auth_type__password_only(): + """Test get_auth_type returns correct values for password-only user.""" + user = get_user() + user.set_password("SecureP@ssw0rd123") + user.save() + text, color = user.get_auth_type() + assert text == "Local" + assert color == "red" + + +@pytest.mark.django_db +def test__user_get_auth_type__oidc_only(): + """Test get_auth_type returns correct values for OIDC-only user.""" + from social_django.models import UserSocialAuth + + user = get_user() + user.set_unusable_password() + user.save() + UserSocialAuth.objects.create(user=user, provider="tunnistamo", uid="test-uid-789") + text, color = user.get_auth_type() + assert text == "Azure AD" + assert color == "green" + + +@pytest.mark.django_db +def test__user_get_auth_type__both(): + """Test get_auth_type returns correct values for user with both auth methods.""" + from social_django.models import UserSocialAuth + + user = get_user() + user.set_password("SecureP@ssw0rd123") + user.save() + UserSocialAuth.objects.create(user=user, provider="tunnistamo", uid="test-uid-101112") + text, color = user.get_auth_type() + assert text == "Both" + assert color == "chocolate" + + +@pytest.mark.django_db +def test__user_get_auth_type__none(): + """Test get_auth_type returns correct values for user with no auth methods.""" + user = get_user() + user.set_unusable_password() + user.save() + text, color = user.get_auth_type() + assert text == "None" + assert color is None