diff --git a/apps/audits/api.py b/apps/audits/api.py index 2c37bf418..4595973ec 100644 --- a/apps/audits/api.py +++ b/apps/audits/api.py @@ -167,7 +167,7 @@ class UserLoginLogViewSet(UserLoginCommonMixin, OrgReadonlyModelViewSet): def get_queryset(self): queryset = super().get_queryset() - queryset = queryset.model.filter_login_queryset_by_org(queryset) + queryset = queryset.model.filter_queryset_by_org(queryset) return queryset @@ -289,12 +289,7 @@ class PasswordChangeLogViewSet(OrgReadonlyModelViewSet): def get_queryset(self): queryset = super().get_queryset() - if not current_org.is_root(): - users = current_org.get_members() - queryset = queryset.filter( - user__in=[str(user) for user in users] - ) - return queryset + return self.model.filter_queryset_by_org(queryset) class UserSessionViewSet(CommonApiMixin, viewsets.ModelViewSet): diff --git a/apps/audits/models.py b/apps/audits/models.py index 5068c8ca3..70ca6257f 100644 --- a/apps/audits/models.py +++ b/apps/audits/models.py @@ -186,6 +186,15 @@ class PasswordChangeLog(models.Model): class Meta: verbose_name = _("Password change log") + @staticmethod + def filter_queryset_by_org(queryset): + if not current_org.is_root(): + users = current_org.get_members() + queryset = queryset.filter( + user__in=[str(user) for user in users] + ) + return queryset + class UserLoginLog(models.Model): id = models.UUIDField(default=uuid.uuid4, primary_key=True) @@ -256,7 +265,7 @@ class UserLoginLog(models.Model): return reason @staticmethod - def filter_login_queryset_by_org(queryset): + def filter_queryset_by_org(queryset): from audits.utils import construct_userlogin_usernames if current_org.is_root() or not settings.XPACK_ENABLED: return queryset diff --git a/apps/reports/api/users/__init__.py b/apps/reports/api/users/__init__.py index f4a2da081..11287d409 100644 --- a/apps/reports/api/users/__init__.py +++ b/apps/reports/api/users/__init__.py @@ -1 +1,2 @@ +from .change_password import * from .user import * diff --git a/apps/reports/api/users/change_password.py b/apps/reports/api/users/change_password.py new file mode 100644 index 000000000..de359868b --- /dev/null +++ b/apps/reports/api/users/change_password.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +from collections import defaultdict + +from django.db.models import Count +from django.http.response import JsonResponse +from rest_framework.views import APIView + +from audits.models import PasswordChangeLog +from common.permissions import IsValidLicense +from common.utils import lazyproperty, get_ip_city, get_logger +from rbac.permissions import RBACPermission +from reports.mixins import DateRangeMixin + +__all__ = ['UserChangeSecretApi'] + +logger = get_logger(__file__) + + +class UserChangeSecretApi(DateRangeMixin, APIView): + http_method_names = ['get'] + # TODO: Define the required RBAC permissions for this API + rbac_perms = { + 'GET': 'users..view_users', + } + permission_classes = [RBACPermission, IsValidLicense] + + @staticmethod + def get_change_password_region_distribution(queryset): + unique_ips = queryset.values_list('remote_addr', flat=True).distinct() + data = defaultdict(int) + for ip in unique_ips: + try: + city = str(get_ip_city(ip)) + if not city: + continue + data[city] += 1 + except Exception: + logger.debug(f"Failed to get city for IP {ip}, skipping", exc_info=True) + continue + return dict(data) + + def get_change_password_metrics(self, queryset): + filtered_queryset = self.filter_by_date_range(queryset, 'datetime') + + data = defaultdict(set) + for t, username in filtered_queryset.values_list('datetime', 'user'): + date_str = str(t.date()) + data[date_str].add(username) + + metrics = [len(data.get(str(d), set())) for d in self.date_range_list] + return metrics + + @lazyproperty + def change_password_queryset(self): + queryset = PasswordChangeLog.objects.all() + return PasswordChangeLog.filter_queryset_by_org(queryset) + + def get(self, request, *args, **kwargs): + data = {} + dates_metrics_date = [date.strftime('%m-%d') for date in self.date_range_list] or ['0'] + + qs = self.filter_by_date_range(self.change_password_queryset, 'datetime') + + total = qs.count() + change_password_top10_users = qs.values( + 'user').annotate(user_count=Count('id')).order_by('-user_count')[:10] + + change_password_top10_change_bys = qs.values( + 'change_by').annotate(user_count=Count('id')).order_by('-user_count')[:10] + + data['total'] = total + data['user_total'] = qs.values('user').distinct().count() + data['change_by_total'] = qs.values('change_by').distinct().count() + data['change_password_top10_users'] = list(change_password_top10_users) + data['change_password_top10_change_bys'] = list(change_password_top10_change_bys) + + data['user_change_password_metrics'] = { + 'dates_metrics_date': dates_metrics_date, + 'dates_metrics_total': self.get_change_password_metrics(qs), + } + + data['change_password_region_distribution'] = self.get_change_password_region_distribution(qs) + return JsonResponse(data, status=200) diff --git a/apps/reports/api/users/user.py b/apps/reports/api/users/user.py index e29f3ecef..ab3d973da 100644 --- a/apps/reports/api/users/user.py +++ b/apps/reports/api/users/user.py @@ -4,20 +4,19 @@ from collections import defaultdict from django.db.models import Count from django.http.response import JsonResponse -from django.utils import timezone from rest_framework.views import APIView from audits.const import LoginStatusChoices from audits.models import UserLoginLog from common.permissions import IsValidLicense from common.utils import lazyproperty -from common.utils.timezone import local_zero_hour, local_now from rbac.permissions import RBACPermission +from reports.mixins import DateRangeMixin __all__ = ['UserReportApi'] -class UserReportApi(APIView): +class UserReportApi(DateRangeMixin, APIView): http_method_names = ['get'] # TODO: Define the required RBAC permissions for this API rbac_perms = { @@ -25,28 +24,6 @@ class UserReportApi(APIView): } permission_classes = [RBACPermission, IsValidLicense] - @lazyproperty - def days(self): - count = self.request.query_params.get('days', 1) - return int(count) - - @property - def days_to_datetime(self): - if self.days == 1: - return local_zero_hour() - return local_now() - timezone.timedelta(days=self.days) - - @lazyproperty - def date_range_list(self): - return [ - (local_now() - timezone.timedelta(days=i)).date() - for i in range(self.days - 1, -1, -1) - ] - - def filter_by_date_range(self, queryset, field_name): - date_range_bounds = self.days_to_datetime.date(), (local_now() + timezone.timedelta(days=1)).date() - return queryset.filter(**{f'{field_name}__range': date_range_bounds}) - def get_user_login_metrics(self, queryset): filtered_queryset = self.filter_by_date_range(queryset, 'datetime') @@ -103,12 +80,12 @@ class UserReportApi(APIView): @lazyproperty def user_login_log_queryset(self): queryset = UserLoginLog.objects.filter(status=LoginStatusChoices.success) - return UserLoginLog.filter_login_queryset_by_org(queryset) + return UserLoginLog.filter_queryset_by_org(queryset) @lazyproperty def user_login_failed_queryset(self): queryset = UserLoginLog.objects.filter(status=LoginStatusChoices.failed) - return UserLoginLog.filter_login_queryset_by_org(queryset) + return UserLoginLog.filter_queryset_by_org(queryset) def get(self, request, *args, **kwargs): data = {} diff --git a/apps/reports/mixins.py b/apps/reports/mixins.py new file mode 100644 index 000000000..b7564d282 --- /dev/null +++ b/apps/reports/mixins.py @@ -0,0 +1,42 @@ +from django.utils import timezone +from rest_framework.request import Request + +from common.utils import lazyproperty +from common.utils.timezone import local_zero_hour, local_now + + +class DateRangeMixin: + request: Request + days_param = 'days' + default_days = 1 + + @lazyproperty + def days(self) -> int: + raw = self.request.query_params.get(self.days_param, self.default_days) + try: + return int(raw) + except (ValueError, TypeError): + return self.default_days + + @property + def start_datetime(self): + if self.days == 1: + return local_zero_hour() + return local_now() - timezone.timedelta(days=self.days) + + @property + def date_range_bounds(self) -> tuple: + start = self.start_datetime.date() + end = (local_now() + timezone.timedelta(days=1)).date() + return start, end + + @lazyproperty + def date_range_list(self) -> list: + return [ + (local_now() - timezone.timedelta(days=i)).date() + for i in range(self.days - 1, -1, -1) + ] + + def filter_by_date_range(self, queryset, field_name: str): + start, end = self.date_range_bounds + return queryset.filter(**{f'{field_name}__range': (start, end)}) diff --git a/apps/reports/urls/api_urls.py b/apps/reports/urls/api_urls.py index b1f40bbf0..69a951691 100644 --- a/apps/reports/urls/api_urls.py +++ b/apps/reports/urls/api_urls.py @@ -6,5 +6,6 @@ app_name = 'reports' urlpatterns = [ path('reports/', api.ReportViewSet.as_view(), name='report-list'), - path('reports/users/', api.UserReportApi.as_view(), name='user-list') + path('reports/users/', api.UserReportApi.as_view(), name='user-list'), + path('reports/user-change-password/', api.UserChangeSecretApi.as_view(), name='user-change-password') ]