diff --git a/apps/account/management/commands/audit_admin_panel_access.py b/apps/account/management/commands/audit_admin_panel_access.py new file mode 100644 index 0000000..9b946d3 --- /dev/null +++ b/apps/account/management/commands/audit_admin_panel_access.py @@ -0,0 +1,187 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import Group + +from apps.account.models import User + + +class Command(BaseCommand): + help = ( + "Audit admin panel access consistency for users. " + "Shows users with panel access and highlights unsafe role/flag/group combinations. " + "Use --fix to automatically normalize unsafe records." + ) + + PANEL_GROUPS = {"Professor Group", "Admin Group", "Super Admin Group", "Super admin Group"} + + def add_arguments(self, parser): + parser.add_argument( + "--fix", + action="store_true", + help="Automatically fix unsafe user records.", + ) + parser.add_argument( + "--email", + action="append", + dest="emails", + help="Limit audit to one or more email addresses. Can be passed multiple times.", + ) + + def handle(self, *args, **options): + should_fix = options["fix"] + emails = options.get("emails") or [] + + queryset = User.objects.filter(email__isnull=False).exclude(email="").order_by("id") + if emails: + queryset = queryset.filter(email__in=emails) + + panel_users = [] + anomalies = [] + fixed_count = 0 + + for user in queryset: + groups = list(user.groups.values_list("name", flat=True)) + can_panel = user.can_access_admin_panel() + + if can_panel or user.is_staff or user.is_superuser or any(g in self.PANEL_GROUPS for g in groups): + panel_users.append( + { + "id": user.id, + "email": user.email, + "user_type": user.user_type, + "is_staff": user.is_staff, + "is_superuser": user.is_superuser, + "groups": groups, + "can_panel": can_panel, + } + ) + + user_anomalies = self.get_anomalies(user, groups) + if user_anomalies: + anomalies.append( + { + "user": user, + "groups": groups, + "anomalies": user_anomalies, + } + ) + + self.stdout.write(self.style.MIGRATE_HEADING("Panel Access Users")) + if panel_users: + for item in panel_users: + self.stdout.write(str(item)) + else: + self.stdout.write(self.style.SUCCESS("No panel-related users found.")) + + self.stdout.write("") + self.stdout.write(self.style.MIGRATE_HEADING("Anomalies")) + if anomalies: + for item in anomalies: + user = item["user"] + self.stdout.write( + self.style.WARNING( + str( + { + "id": user.id, + "email": user.email, + "user_type": user.user_type, + "is_staff": user.is_staff, + "is_superuser": user.is_superuser, + "groups": item["groups"], + "anomalies": item["anomalies"], + } + ) + ) + ) + if should_fix: + if self.fix_user(user, item["anomalies"]): + fixed_count += 1 + else: + self.stdout.write(self.style.SUCCESS("No anomalies found.")) + + if should_fix: + self.stdout.write("") + self.stdout.write(self.style.SUCCESS(f"Fixed {fixed_count} user(s).")) + + def get_anomalies(self, user, groups): + anomalies = [] + + if user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT, User.UserType.CONSULTANT]: + if user.is_staff or user.is_superuser: + anomalies.append("low_role_with_staff_flags") + if any(group in self.PANEL_GROUPS for group in groups): + anomalies.append("low_role_with_panel_group") + if user.can_access_admin_panel(): + anomalies.append("low_role_can_access_panel") + + if user.user_type == User.UserType.PROFESSOR and "Professor Group" not in groups: + anomalies.append("professor_missing_group") + + if user.user_type == User.UserType.ADMIN and "Admin Group" not in groups: + anomalies.append("admin_missing_group") + + if user.user_type == User.UserType.SUPER_ADMIN and not any( + group in groups for group in ["Super Admin Group", "Super admin Group"] + ): + anomalies.append("super_admin_missing_group") + + if user.user_type == User.UserType.SUPER_ADMIN and not user.is_superuser: + anomalies.append("super_admin_missing_superuser_flag") + + return anomalies + + def fix_user(self, user, anomalies): + changed = False + + if user.user_type == User.UserType.SUPER_ADMIN: + if not user.is_staff: + user.is_staff = True + changed = True + if not user.is_superuser: + user.is_superuser = True + changed = True + changed = self.ensure_group(user, "Super Admin Group") or changed + changed = self.remove_groups(user, {"Professor Group", "Admin Group", "Super admin Group"}) or changed + + elif user.user_type == User.UserType.ADMIN: + if user.is_superuser: + user.is_superuser = False + changed = True + changed = self.ensure_group(user, "Admin Group") or changed + changed = self.remove_groups(user, {"Professor Group", "Super Admin Group", "Super admin Group"}) or changed + + elif user.user_type == User.UserType.PROFESSOR: + if user.is_superuser: + user.is_superuser = False + changed = True + changed = self.ensure_group(user, "Professor Group") or changed + changed = self.remove_groups(user, {"Admin Group", "Super Admin Group", "Super admin Group"}) or changed + + elif user.user_type in [User.UserType.STUDENT, User.UserType.CLIENT, User.UserType.CONSULTANT]: + if user.is_staff: + user.is_staff = False + changed = True + if user.is_superuser: + user.is_superuser = False + changed = True + changed = self.remove_groups(user, self.PANEL_GROUPS) or changed + + if changed: + user.save() + self.stdout.write(self.style.SUCCESS(f"Fixed user {user.id} <{user.email}>")) + + return changed + + def ensure_group(self, user, group_name): + group, _ = Group.objects.get_or_create(name=group_name) + if not user.groups.filter(id=group.id).exists(): + user.groups.add(group) + return True + return False + + def remove_groups(self, user, group_names): + groups = Group.objects.filter(name__in=group_names) + existing_ids = set(user.groups.filter(id__in=groups.values("id")).values_list("id", flat=True)) + if existing_ids: + user.groups.remove(*groups) + return True + return False diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 1bebcd6..0d3b94c 100644 --- a/apps/account/models/user.py +++ b/apps/account/models/user.py @@ -166,23 +166,14 @@ class User(AbstractUser): def has_role(self, role_name): """چک کردن داشتن نقش خاص""" - if isinstance(role_name, str): - # اگر نام نقش به صورت string داده شده - group_name = f"{role_name.capitalize()} Group" - else: - # اگر از enum استفاده شده - group_name = f"{role_name.value.capitalize()} Group" + group_name = self._role_to_group_name(role_name) return self.groups.filter(name=group_name).exists() def add_role(self, role_name): """اضافه کردن نقش جدید بدون حذف نقش‌های قبلی""" from django.contrib.auth.models import Group - if isinstance(role_name, str): - group_name = f"{role_name.capitalize()} Group" - else: - group_name = f"{role_name.value.capitalize()} Group" - + group_name = self._role_to_group_name(role_name) group, created = Group.objects.get_or_create(name=group_name) self.groups.add(group) @@ -198,11 +189,7 @@ class User(AbstractUser): """حذف نقش خاص""" from django.contrib.auth.models import Group - if isinstance(role_name, str): - group_name = f"{role_name.capitalize()} Group" - else: - group_name = f"{role_name.value.capitalize()} Group" - + group_name = self._role_to_group_name(role_name) try: group = Group.objects.get(name=group_name) self.groups.remove(group) @@ -218,6 +205,19 @@ class User(AbstractUser): return [group.name.replace(' Group', '').lower() for group in self.groups.all()] + def _role_to_group_name(self, role_name): + role_value = role_name.value if hasattr(role_name, 'value') else str(role_name) + role_key = role_value.strip().lower() + role_group_map = { + 'student': 'Student Group', + 'professor': 'Professor Group', + 'consultant': 'Consultant Group', + 'admin': 'Admin Group', + 'super_admin': 'Super Admin Group', + 'client': 'Client Group', + } + return role_group_map.get(role_key, f"{role_value.replace('_', ' ').title()} Group") + def can_teach_course(self): """آیا می‌تواند دوره تدریس کند؟""" # اولویت اول: staff یا admin @@ -226,6 +226,42 @@ class User(AbstractUser): # اولویت دوم: professor return self.has_role('professor') + def is_super_admin_panel_user(self): + """آیا کاربر دسترسی کامل پنل را به عنوان سوپر ادمین دارد؟""" + return bool( + self.is_active and ( + self.is_superuser or + self.user_type == self.UserType.SUPER_ADMIN or + self.has_role('super_admin') + ) + ) + + def is_admin_panel_user(self): + """آیا کاربر دسترسی کامل پنل را به عنوان ادمین دارد؟""" + return bool( + self.is_active and ( + self.user_type == self.UserType.ADMIN or + self.has_role('admin') + ) + ) + + def is_professor_panel_user(self): + """آیا کاربر از طریق نقش استاد اجازه ورود به پنل را دارد؟""" + return bool( + self.is_active and ( + self.user_type == self.UserType.PROFESSOR or + self.has_role('professor') + ) + ) + + def can_access_admin_panel(self): + """سوپر ادمین‌ها، ادمین‌ها و استادها اجازه ورود به پنل را دارند.""" + return ( + self.is_super_admin_panel_user() or + self.is_admin_panel_user() or + self.is_professor_panel_user() + ) + def can_enroll_course(self): """آیا می‌تواند در دوره ثبت‌نام کند؟""" return True # همه می‌توانند دانش‌آموز باشند diff --git a/apps/account/permissions.py b/apps/account/permissions.py index 2b5555e..b5679c5 100644 --- a/apps/account/permissions.py +++ b/apps/account/permissions.py @@ -1,58 +1,56 @@ - - - - - -from rest_framework.permissions import BasePermission, SAFE_METHODS - - -class IsActiveUser(BasePermission): - - def has_permission(self, request, view): - return request.user and request.user.is_active - - -class IsSuperAdmin(BasePermission): - """ - Allows access only to super admins and staff admins. - """ - def has_permission(self, request, view): - return ( - request.user and - request.user.is_authenticated and - request.user.is_active and - (request.user.is_superuser or request.user.user_type in ['super_admin', 'admin']) - ) - - -class IsPanelUser(BasePermission): - """ - Allows access to all administrative panel roles: super admins, admins, and professors. - """ - def has_permission(self, request, view): - return ( - request.user and - request.user.is_authenticated and - request.user.is_active and - (request.user.is_superuser or request.user.user_type in ['super_admin', 'admin', 'professor']) - ) - - -class IsSuperAdminOrReadOnlyForProfessor(BasePermission): - """ - Allows full read-write access to super admins/admins, - but only read-only (GET, HEAD, OPTIONS) access to professors. - """ - def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated or not request.user.is_active: - return False - - # Super admin / Admin can do everything - if request.user.is_superuser or request.user.user_type in ['super_admin', 'admin']: - return True - - # Professor has read-only access - if request.user.user_type == 'professor': - return request.method in SAFE_METHODS - - return False \ No newline at end of file + + + + + +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsActiveUser(BasePermission): + + def has_permission(self, request, view): + return request.user and request.user.is_active + + +class IsSuperAdmin(BasePermission): + """ + Allows access to super admins and admins with full panel privileges. + """ + def has_permission(self, request, view): + return ( + request.user and + request.user.is_authenticated and + (request.user.is_super_admin_panel_user() or request.user.is_admin_panel_user()) + ) + + +class IsPanelUser(BasePermission): + """ + Allows access to super admins, admins, and professors. + """ + def has_permission(self, request, view): + return ( + request.user and + request.user.is_authenticated and + request.user.can_access_admin_panel() + ) + + +class IsSuperAdminOrReadOnlyForProfessor(BasePermission): + """ + Allows full read-write access to super admins and admins, + but only read-only (GET, HEAD, OPTIONS) access to professors. + """ + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated or not request.user.is_active: + return False + + # Super admin / admin can do everything + if request.user.is_super_admin_panel_user() or request.user.is_admin_panel_user(): + return True + + # Professor has read-only access + if request.user.is_professor_panel_user(): + return request.method in SAFE_METHODS + + return False diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index 7aa69df..87ca904 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -1,5 +1,6 @@ from rest_framework import serializers from rest_framework.authtoken.models import Token +from django.contrib.auth.models import Group from django.contrib.auth.password_validation import validate_password from django.utils.translation import gettext_lazy as _ from apps.account.models import User @@ -250,6 +251,70 @@ class AdminUserSerializer(serializers.ModelSerializer): ] read_only_fields = ['id', 'date_joined', 'last_login', 'auth_token', 'device_id', 'device_os', 'user_agent', 'client_ip'] + ROLE_GROUP_MAP = { + User.UserType.STUDENT: "Student Group", + User.UserType.PROFESSOR: "Professor Group", + User.UserType.ADMIN: "Admin Group", + User.UserType.SUPER_ADMIN: "Super Admin Group", + User.UserType.CONSULTANT: "Consultant Group", + } + PANEL_GROUPS = {"Professor Group", "Admin Group", "Super admin Group", "Super Admin Group"} + + def _ensure_group(self, instance, group_name): + group, _ = Group.objects.get_or_create(name=group_name) + instance.groups.add(group) + + def _remove_groups(self, instance, group_names): + if not group_names: + return + instance.groups.remove(*Group.objects.filter(name__in=group_names)) + + def _normalize_role_access(self, instance, requested_user_type): + if not requested_user_type: + return + + requested_user_type = str(requested_user_type) + + if requested_user_type == User.UserType.SUPER_ADMIN: + instance.is_staff = True + instance.is_superuser = True + self._ensure_group(instance, "Super Admin Group") + self._remove_groups(instance, {"Professor Group", "Admin Group", "Super admin Group"}) + return + + if requested_user_type == User.UserType.PROFESSOR: + instance.is_staff = False + instance.is_superuser = False + self._ensure_group(instance, "Professor Group") + self._remove_groups(instance, {"Admin Group", "Super admin Group", "Super Admin Group"}) + return + + if requested_user_type == User.UserType.ADMIN: + instance.is_staff = False + instance.is_superuser = False + self._ensure_group(instance, "Admin Group") + self._remove_groups(instance, {"Professor Group", "Super admin Group", "Super Admin Group"}) + return + + if requested_user_type == User.UserType.STUDENT: + instance.is_staff = False + instance.is_superuser = False + self._ensure_group(instance, "Student Group") + self._remove_groups(instance, self.PANEL_GROUPS) + return + + if requested_user_type == User.UserType.CONSULTANT: + instance.is_staff = False + instance.is_superuser = False + self._ensure_group(instance, "Consultant Group") + self._remove_groups(instance, self.PANEL_GROUPS) + return + + if requested_user_type == User.UserType.CLIENT: + instance.is_staff = False + instance.is_superuser = False + self._remove_groups(instance, self.PANEL_GROUPS) + def create(self, validated_data): password = validated_data.pop('password', None) user = User(**validated_data) @@ -259,13 +324,16 @@ class AdminUserSerializer(serializers.ModelSerializer): else: user.set_unusable_password() user.save() + self._normalize_role_access(user, validated_data.get('user_type', user.user_type)) + user.save() return user def update(self, instance, validated_data): password = validated_data.pop('password', None) user = super().update(instance, validated_data) + self._normalize_role_access(user, validated_data.get('user_type', user.user_type)) if password: user.set_password(password) user.set_plain_password(password) - user.save() + user.save() return user diff --git a/apps/account/tests/test_admin_panel_access.py b/apps/account/tests/test_admin_panel_access.py new file mode 100644 index 0000000..32c8ad9 --- /dev/null +++ b/apps/account/tests/test_admin_panel_access.py @@ -0,0 +1,125 @@ +from django.contrib.auth.models import Group +from django.urls import reverse +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.test import APITestCase + +from apps.account.models import User + + +class AdminPanelAccessTests(APITestCase): + def setUp(self): + self.student_group = Group.objects.create(name="Student Group") + self.professor_group = Group.objects.create(name="Professor Group") + self.admin_group = Group.objects.create(name="Admin Group") + self.super_admin_group = Group.objects.create(name="Super Admin Group") + + self.super_admin = User.objects.create_superuser( + email="superadmin@example.com", + password="SuperSecret123!", + fullname="Super Admin", + ) + self.super_admin.language = None + self.super_admin.save() + self.super_admin_token = Token.objects.create(user=self.super_admin) + + def _create_user(self, **kwargs): + password = kwargs.pop("password") + user = User.objects.create_user(password=password, **kwargs) + user.language = None + user.save() + return user + + def test_student_with_staff_flag_cannot_login_to_admin_panel(self): + user = self._create_user( + email="student@example.com", + password="StudentPass123!", + fullname="Student User", + user_type=User.UserType.STUDENT, + is_staff=True, + ) + + response = self.client.post( + reverse("admin-login"), + {"email": user.email, "password": "StudentPass123!"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_professor_can_login_to_admin_panel(self): + professor = self._create_user( + email="professor@example.com", + password="ProfessorPass123!", + fullname="Professor User", + user_type=User.UserType.PROFESSOR, + ) + professor.add_role("professor") + + response = self.client.post( + reverse("admin-login"), + {"email": professor.email, "password": "ProfessorPass123!"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["user_type"], "professor") + + def test_admin_can_login_to_admin_panel(self): + admin = self._create_user( + email="admin@example.com", + password="AdminPass123!", + fullname="Admin User", + user_type=User.UserType.ADMIN, + ) + admin.add_role("admin") + + response = self.client.post( + reverse("admin-login"), + {"email": admin.email, "password": "AdminPass123!"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["user_type"], "admin") + + def test_updating_user_to_student_revokes_panel_access(self): + target_user = self._create_user( + email="target@example.com", + password="TargetPass123!", + fullname="Target User", + user_type=User.UserType.PROFESSOR, + is_staff=True, + ) + target_user.add_role("professor") + target_user.add_role("admin") + target_user.is_superuser = True + target_user.save(update_fields=["is_superuser", "is_staff"]) + + self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.super_admin_token.key}") + update_response = self.client.patch( + reverse("admin-users-detail", args=[target_user.id]), + { + "user_type": User.UserType.STUDENT, + "is_staff": True, + "is_superuser": True, + }, + format="json", + ) + + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + + target_user.refresh_from_db() + self.assertEqual(target_user.user_type, User.UserType.STUDENT) + self.assertFalse(target_user.is_staff) + self.assertFalse(target_user.is_superuser) + self.assertFalse(target_user.has_role("professor")) + self.assertFalse(target_user.has_role("admin")) + + self.client.credentials() + login_response = self.client.post( + reverse("admin-login"), + {"email": target_user.email, "password": "TargetPass123!"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/apps/account/views/user.py b/apps/account/views/user.py index 8e53acd..4ad6cae 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -589,24 +589,14 @@ class AdminLoginView(CreateAPIView): except User.DoesNotExist: raise ValidationError({"email": "No admin found with that information."}) - # --- PANEL ACCESS CHECK --- - # Allow super_admin, admin (is_staff/is_superuser), professors - # Check both user_type field AND group membership (in case they differ) - ALLOWED_TYPES = ('professor', 'admin', 'super_admin') - is_panel_user = ( - user_obj.is_staff or - user_obj.is_superuser or - getattr(user_obj, 'user_type', None) in ALLOWED_TYPES or - user_obj.groups.filter(name="Professor Group").exists() - ) - if not is_panel_user: - raise AuthenticationFailed("No admin found with that information.") - # Use the actual stored email for authentication (ensures case matches DB) user = authenticate(request, username=user_obj.email, password=data['password']) if not user: raise ValidationError({"password": "Password is incorrect"}) + if not user.can_access_admin_panel(): + raise AuthenticationFailed("No admin found with that information.") + user_timezone = serializer.validated_data.pop('timezone', None) user.last_login = timezone.now() user.is_active = True @@ -621,11 +611,12 @@ class AdminLoginView(CreateAPIView): device_os='web_admin' ) - # Determine effective user_type (user_type field may lag behind groups) - effective_user_type = user.user_type - if effective_user_type not in ('professor', 'admin', 'super_admin'): - if user.groups.filter(name="Professor Group").exists(): - effective_user_type = 'professor' + if user.is_super_admin_panel_user(): + effective_user_type = 'super_admin' + elif user.is_admin_panel_user(): + effective_user_type = 'admin' + else: + effective_user_type = 'professor' return Response({ "id": user.id,