From 768ddaba95f5c65dc1464ff4941343f70b006613 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 23:07:36 +0200 Subject: [PATCH 1/2] refactor(user): extract user module into dojo/user/ [user Phase 1,3,4,5,6,7,8,9] --- dojo/api_v2/serializers.py | 187 +------------------------- dojo/api_v2/views.py | 82 ------------ dojo/filters.py | 73 +--------- dojo/forms.py | 107 --------------- dojo/metrics/views.py | 2 +- dojo/models.py | 75 +---------- dojo/urls.py | 10 +- dojo/user/__init__.py | 1 + dojo/user/admin.py | 6 + dojo/user/api/__init__.py | 1 + dojo/user/api/filters.py | 44 +++++++ dojo/user/api/serializer.py | 190 +++++++++++++++++++++++++++ dojo/user/api/urls.py | 7 + dojo/user/api/views.py | 102 ++++++++++++++ dojo/user/models.py | 78 +++++++++++ dojo/user/ui/__init__.py | 0 dojo/user/ui/filters.py | 36 +++++ dojo/user/ui/forms.py | 114 ++++++++++++++++ dojo/user/{ => ui}/urls.py | 2 +- dojo/user/{ => ui}/views.py | 12 +- unittests/test_rest_framework.py | 3 +- unittests/test_user_ui_timestamps.py | 4 +- 22 files changed, 606 insertions(+), 530 deletions(-) create mode 100644 dojo/user/admin.py create mode 100644 dojo/user/api/__init__.py create mode 100644 dojo/user/api/filters.py create mode 100644 dojo/user/api/serializer.py create mode 100644 dojo/user/api/urls.py create mode 100644 dojo/user/api/views.py create mode 100644 dojo/user/models.py create mode 100644 dojo/user/ui/__init__.py create mode 100644 dojo/user/ui/filters.py create mode 100644 dojo/user/ui/forms.py rename dojo/user/{ => ui}/urls.py (99%) rename dojo/user/{ => ui}/views.py (99%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 83d043aaaac..4f8794fcb5c 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -8,7 +8,6 @@ import tagulous from django.conf import settings from django.contrib.auth.models import Permission -from django.contrib.auth.password_validation import validate_password from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.db.utils import IntegrityError @@ -36,7 +35,6 @@ Announcement, App_Analysis, Development_Environment, - Dojo_User, DojoMeta, Endpoint, Endpoint_Params, @@ -64,7 +62,6 @@ Tool_Product_Settings, Tool_Type, User, - UserContactInfo, get_current_date, ) from dojo.product_announcements import ( @@ -76,7 +73,6 @@ requires_file, requires_tool_type, ) -from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import is_scan_file_too_large from dojo.validators import ImporterFileExtensionValidator, tag_validator @@ -303,177 +299,13 @@ def validate(self, data): return data -class UserSerializer(serializers.ModelSerializer): - date_joined = serializers.DateTimeField(read_only=True) - last_login = serializers.DateTimeField(read_only=True, allow_null=True) - email = serializers.EmailField(required=True) - token_last_reset = serializers.SerializerMethodField() - password_last_reset = serializers.SerializerMethodField() - password = serializers.CharField( - write_only=True, - style={"input_type": "password"}, - required=False, - validators=[validate_password], - ) - configuration_permissions = serializers.PrimaryKeyRelatedField( - allow_null=True, - queryset=Permission.objects.filter( - codename__in=get_configuration_permissions_codenames(), - ), - many=True, - required=False, - source="user_permissions", - ) - - class Meta: - model = Dojo_User - fields = ( - "id", - "username", - "first_name", - "last_name", - "email", - "date_joined", - "last_login", - "is_active", - "is_staff", - "is_superuser", - "token_last_reset", - "password_last_reset", - "password", - "configuration_permissions", - ) - - @extend_schema_field(serializers.DateTimeField(allow_null=True)) - def get_token_last_reset(self, instance): - uci = getattr(instance, "usercontactinfo", None) - return getattr(uci, "token_last_reset", None) - - @extend_schema_field(serializers.DateTimeField(allow_null=True)) - def get_password_last_reset(self, instance): - uci = getattr(instance, "usercontactinfo", None) - return getattr(uci, "password_last_reset", None) - - def to_representation(self, instance): - ret = super().to_representation(instance) - - # This will show only "configuration_permissions" even if user has also - # other permissions - all_permissions = set(ret["configuration_permissions"]) - allowed_configuration_permissions = set( - self.fields[ - "configuration_permissions" - ].child_relation.queryset.values_list("id", flat=True), - ) - ret["configuration_permissions"] = list( - all_permissions.intersection(allowed_configuration_permissions), - ) - - return ret - - def update(self, instance, validated_data): - permissions_in_payload = None - new_configuration_permissions = None - if ( - "user_permissions" in validated_data - ): # This field was renamed from "configuration_permissions" in the meantime - permissions_in_payload = validated_data.pop("user_permissions") - new_configuration_permissions = set(permissions_in_payload) - - instance = super().update(instance, validated_data) - - # This will update only Permissions from category - # "configuration_permissions". Others will be untouched - if new_configuration_permissions: - allowed_configuration_permissions = set( - self.fields[ - "configuration_permissions" - ].child_relation.queryset.all(), - ) - non_configuration_permissions = ( - set(instance.user_permissions.all()) - - allowed_configuration_permissions - ) - new_permissions = non_configuration_permissions.union( - new_configuration_permissions, - ) - instance.user_permissions.set(new_permissions) - - # Clear all configuration permissions if an empty list is provided - if isinstance(permissions_in_payload, list) and len(permissions_in_payload) == 0: - instance.user_permissions.clear() - - return instance - - def create(self, validated_data): - password = validated_data.pop("password", None) - - new_configuration_permissions = None - if ( - "user_permissions" in validated_data - ): # This field was renamed from "configuration_permissions" in the meantime - new_configuration_permissions = set( - validated_data.pop("user_permissions"), - ) - - user = Dojo_User.objects.create(**validated_data) - - if password: - user.set_password(password) - else: - user.set_unusable_password() - - # This will create only Permissions from category - # "configuration_permissions". There are no other Permissions. - if new_configuration_permissions: - user.user_permissions.set(new_configuration_permissions) - - user.save() - return user - - def validate(self, data): - instance_is_superuser = self.instance.is_superuser if self.instance is not None else False - data_is_superuser = data.get("is_superuser", False) - if not self.context["request"].user.is_superuser and ( - instance_is_superuser or data_is_superuser - ): - msg = "Only superusers are allowed to add or edit superusers." - raise ValidationError(msg) - - if self.context["request"].method in {"PATCH", "PUT"} and "password" in data: - msg = "Update of password though API is not allowed" - raise ValidationError(msg) - if self.context["request"].method == "POST" and "password" not in data and settings.REQUIRE_PASSWORD_ON_USER: - msg = "Passwords must be supplied for new users" - raise ValidationError(msg) - return super().validate(data) - - -class UserContactInfoSerializer(serializers.ModelSerializer): - user_profile = UserSerializer(many=False, source="user", read_only=True) - - class Meta: - model = UserContactInfo - fields = "__all__" - - def validate(self, data): - user = data.get("user", None) or self.instance.user - if data.get("force_password_reset", False) and not user.has_usable_password(): - msg = "Password resets are not allowed for users authorized through SSO." - raise ValidationError(msg) - return super().validate(data) - - -class UserStubSerializer(serializers.ModelSerializer): - class Meta: - model = Dojo_User - fields = ("id", "username", "first_name", "last_name") - - -class AddUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ("id", "username") +from dojo.user.api.serializer import ( # noqa: E402, F401 -- backward compat + prefetcher discovery + AddUserSerializer, + UserContactInfoSerializer, + UserProfileSerializer, + UserSerializer, + UserStubSerializer, +) class NoteTypeSerializer(serializers.ModelSerializer): @@ -1681,11 +1513,6 @@ def validate(self, data): return data -class UserProfileSerializer(serializers.Serializer): - user = UserSerializer(many=False) - user_contact_info = UserContactInfoSerializer(many=False, required=False) - - class DeletePreviewSerializer(serializers.Serializer): model = serializers.CharField(read_only=True) id = serializers.IntegerField(read_only=True, allow_null=True) diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 09bc52e7252..2e6d706ee27 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -4,7 +4,6 @@ from pathlib import Path import pghistory -from crum import get_current_user from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import Permission @@ -27,7 +26,6 @@ from drf_spectacular.views import SpectacularAPIView from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated from rest_framework.response import Response @@ -51,7 +49,6 @@ ApiDojoMetaFilter, ApiEndpointFilter, ApiRiskAcceptanceFilter, - ApiUserFilter, ) from dojo.finding.ui.filters import ( ReportFindingFilter, @@ -86,8 +83,6 @@ Tool_Configuration, Tool_Product_Settings, Tool_Type, - User, - UserContactInfo, ) from dojo.product.queries import ( get_authorized_app_analysis, @@ -104,7 +99,6 @@ from dojo.risk_acceptance.queries import get_authorized_risk_acceptances from dojo.test.queries import get_authorized_tests from dojo.tool_product.queries import get_authorized_tool_product_settings -from dojo.user.authentication import reset_token_for_user from dojo.user.utils import get_configuration_permissions_codenames from dojo.utils import ( get_celery_queue_details, @@ -654,82 +648,6 @@ def get_queryset(self): return Regulation.objects.all().order_by("id") -# Authorization: configuration -class UsersViewSet( - DojoModelViewSet, -): - serializer_class = serializers.UserSerializer - queryset = User.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_class = ApiUserFilter - permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) - - def get_queryset(self): - return User.objects.all().order_by("id") - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - if request.user == instance: - return Response( - "Users may not delete themselves", - status=status.HTTP_400_BAD_REQUEST, - ) - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) - - @action( - detail=True, - methods=["post"], - url_path="reset_api_token", - permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner), - filter_backends=[], - pagination_class=None, - ) - def reset_api_token(self, request, pk=None): - target_user = self.get_object() - reset_token_for_user(acting_user=request.user, target_user=target_user) - return Response(status=status.HTTP_204_NO_CONTENT) - - -# Authorization: superuser -@extend_schema_view(**schema_with_prefetch()) -class UserContactInfoViewSet( - PrefetchDojoModelViewSet, -): - serializer_class = serializers.UserContactInfoSerializer - queryset = UserContactInfo.objects.none() - filter_backends = (DjangoFilterBackend,) - filterset_fields = "__all__" - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) - - def get_queryset(self): - return UserContactInfo.objects.all().order_by("id") - - -# Authorization: authenticated users -class UserProfileView(GenericAPIView): - permission_classes = (IsAuthenticated,) - pagination_class = None - serializer_class = serializers.UserProfileSerializer - - @action( - detail=True, methods=["get"], filter_backends=[], pagination_class=None, - ) - def get(self, request, _=None): - user = get_current_user() - user_contact_info = ( - user.usercontactinfo if hasattr(user, "usercontactinfo") else None - ) - serializer = serializers.UserProfileSerializer( - { - "user": user, - "user_contact_info": user_contact_info, - }, - many=False, - ) - return Response(serializer.data) - - # Authorization: authenticated users, DjangoModelPermissions class ImportScanView(mixins.CreateModelMixin, viewsets.GenericViewSet): diff --git a/dojo/filters.py b/dojo/filters.py index ffd44138885..43f588a4503 100644 --- a/dojo/filters.py +++ b/dojo/filters.py @@ -50,7 +50,6 @@ App_Analysis, ChoiceQuestion, Development_Environment, - Dojo_User, DojoMeta, Endpoint, Endpoint_Status, @@ -64,7 +63,6 @@ Risk_Acceptance, Test, TextQuestion, - User, Vulnerability_Id, ) from dojo.product.queries import get_authorized_products @@ -1649,38 +1647,7 @@ class Meta: exclude = ["product"] -class UserFilter(DojoFilter): - first_name = CharFilter(lookup_expr="icontains") - last_name = CharFilter(lookup_expr="icontains") - username = CharFilter(lookup_expr="icontains") - email = CharFilter(lookup_expr="icontains") - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("username", "username"), - ("last_name", "last_name"), - ("first_name", "first_name"), - ("email", "email"), - ("is_active", "is_active"), - ("is_superuser", "is_superuser"), - ("is_staff", "is_staff"), - ("date_joined", "date_joined"), - ("last_login", "last_login"), - ), - field_labels={ - "username": "User Name", - "is_active": "Active", - "is_superuser": "Superuser", - "is_staff": "Staff", - }, - ) - - class Meta: - model = Dojo_User - fields = ["is_superuser", "is_staff", "is_active", "first_name", "last_name", "username", "email"] - - +# UserFilter lives in dojo/user/ui/filters.py — import from there directly. # TestImportFilter and TestImportFindingActionFilter live in dojo/test/ui/filters.py and are # re-exported at the bottom of this module for backward compatibility. @@ -1770,43 +1737,7 @@ def filter(self, qs, value): return self.options[value][1](self, qs, self.options[value][0]) -class ApiUserFilter(filters.FilterSet): - last_login = filters.DateFromToRangeFilter() - date_joined = filters.DateFromToRangeFilter() - is_active = filters.BooleanFilter() - is_superuser = filters.BooleanFilter() - username = filters.CharFilter(lookup_expr="icontains") - first_name = filters.CharFilter(lookup_expr="icontains") - last_name = filters.CharFilter(lookup_expr="icontains") - email = filters.CharFilter(lookup_expr="icontains") - class Meta: - model = User - fields = [ - "id", - "username", - "first_name", - "last_name", - "email", - "is_active", - "is_superuser", - "last_login", - "date_joined", - ] - - o = OrderingFilter( - # tuple-mapping retains order - fields=( - ("username", "username"), - ("last_name", "last_name"), - ("first_name", "first_name"), - ("email", "email"), - ("is_active", "is_active"), - ("is_superuser", "is_superuser"), - ("date_joined", "date_joined"), - ("last_login", "last_login"), - ), - ) - +# ApiUserFilter lives in dojo/user/api/filters.py — import from there directly. with warnings.catch_warnings(action="ignore", category=ManagerInheritanceWarning): class QuestionFilter(FilterSet): diff --git a/dojo/forms.py b/dojo/forms.py index 8d81d1510e4..5a0a9828392 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -26,7 +26,6 @@ from polymorphic.base import ManagerInheritanceWarning from tagulous.forms import TagField -from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add from dojo.finding.queries import get_authorized_findings from dojo.github.ui.forms import ( # noqa: F401 -- backward compat @@ -92,7 +91,6 @@ Tool_Product_Settings, Tool_Type, User, - UserContactInfo, ) from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types @@ -1008,20 +1006,6 @@ def __init__(self, *args, **kwargs): del self.fields["exclude_product_types"] -class DojoUserForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not get_current_user().is_superuser and not get_system_setting("enable_user_profile_editable"): - for field in self.fields: - self.fields[field].disabled = True - - class Meta: - model = Dojo_User - exclude = ["password", "last_login", "is_superuser", "groups", - "username", "is_staff", "is_active", "date_joined", - "user_permissions"] - - class ChangePasswordForm(forms.Form): current_password = forms.CharField(widget=forms.PasswordInput, required=True) @@ -1061,97 +1045,6 @@ def clean(self): return cleaned_data -class AddDojoUserForm(forms.ModelForm): - email = forms.EmailField(required=True) - password = forms.CharField(widget=forms.PasswordInput, - required=settings.REQUIRE_PASSWORD_ON_USER, - validators=[validate_password], - help_text="") - - class Meta: - model = Dojo_User - fields = ["username", "password", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - current_user = get_current_user() - if not current_user.is_superuser: - self.fields["is_staff"].disabled = True - self.fields["is_superuser"].disabled = True - self.fields["password"].help_text = get_password_requirements_string() - - -class EditDojoUserForm(forms.ModelForm): - email = forms.EmailField(required=True) - - class Meta: - model = Dojo_User - fields = ["username", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - current_user = get_current_user() - if not current_user.is_superuser: - self.fields["is_staff"].disabled = True - self.fields["is_superuser"].disabled = True - - -class DeleteUserForm(forms.ModelForm): - id = forms.IntegerField(required=True, - widget=forms.widgets.HiddenInput()) - - class Meta: - model = User - fields = ["id"] - - -class UserContactInfoForm(forms.ModelForm): - reset_api_token = forms.BooleanField( - required=False, - label=_("Reset API token"), - help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."), - ) - - class Meta: - model = UserContactInfo - exclude = ["user", "slack_user_id"] - # Swap order: password_last_reset before token_last_reset - field_order = [ - "title", "phone_number", "cell_number", "twitter_username", "github_username", - "slack_username", "ui_use_tailwind", "block_execution", "force_password_reset", "reset_api_token", - "password_last_reset", "token_last_reset", - ] - - def __init__(self, *args, **kwargs): - user = kwargs.pop("user", None) - super().__init__(*args, **kwargs) - # Make timestamp fields readonly. - # NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields - # are ignored during binding/cleaning, so these timestamps cannot be modified via this form. - if "password_last_reset" in self.fields: - self.fields["password_last_reset"].disabled = True - if "token_last_reset" in self.fields: - self.fields["token_last_reset"].disabled = True - # Do not expose force password reset if the current user does not have a password to reset - if user is not None: - if not user.has_usable_password(): - self.fields["force_password_reset"].disabled = True - self.fields["force_password_reset"].help_text = "This user is authorized through SSO, and does not have a password to reset" - # Determine some other settings based on the current user - current_user = get_current_user() - if not current_user.is_superuser: - if not user_has_configuration_permission(current_user, "auth.change_user") and \ - not user_has_configuration_permission(current_user, "auth.add_user"): - self.fields.pop("force_password_reset", None) - if not get_system_setting("enable_user_profile_editable"): - for field in self.fields: - self.fields[field].disabled = True - - # Only show reset_api_token to superusers or global owners, and only if API tokens are enabled - if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user): - self.fields.pop("reset_api_token", None) - - # Product forms live in dojo/product/ui/forms.py. Re-exported here for backward # compat: ProductCountsFormBase is subclassed by ProductTypeCountsForm below, # Authorize_User_For_ProductsForm by dojo/user/views.py, ProductTagCountsForm by diff --git a/dojo/metrics/views.py b/dojo/metrics/views.py index 28141bc95de..c2321d5a450 100644 --- a/dojo/metrics/views.py +++ b/dojo/metrics/views.py @@ -19,7 +19,6 @@ from django.views.decorators.vary import vary_on_cookie from dojo.authorization.authorization import user_has_permission_or_403 -from dojo.filters import UserFilter from dojo.forms import ProductTagCountsForm, ProductTypeCountsForm, SimpleMetricsForm from dojo.labels import get_labels from dojo.metrics.utils import ( @@ -35,6 +34,7 @@ from dojo.models import Dojo_User, Finding, Product_Type, Risk_Acceptance from dojo.product.queries import get_authorized_products from dojo.product_type.queries import get_authorized_product_types +from dojo.user.ui.filters import UserFilter from dojo.utils import ( add_breadcrumb, count_findings, diff --git a/dojo/models.py b/dojo/models.py index 4dfc2336700..09f4bfd2c84 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -16,7 +16,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator, validate_ipv46_address +from django.core.validators import MaxValueValidator, MinValueValidator, validate_ipv46_address from django.db import connection, models from django.db.models import Count, F, Q from django.db.models.expressions import Case, When @@ -169,67 +169,7 @@ def __str__(self): User = get_user_model() -# proxy class for convenience and UI -class Dojo_User(User): - class Meta: - proxy = True - ordering = ["first_name"] - - def get_full_name(self): - return Dojo_User.generate_full_name(self) - - def __str__(self): - return self.get_full_name() - - @staticmethod - def wants_block_execution(user): - # this return False if there is no user, i.e. in celery processes, unittests, etc. - return hasattr(user, "usercontactinfo") and user.usercontactinfo.block_execution - - @staticmethod - def force_password_reset(user): - return hasattr(user, "usercontactinfo") and user.usercontactinfo.force_password_reset - - def disable_force_password_reset(self): - if hasattr(self, "usercontactinfo"): - self.usercontactinfo.force_password_reset = False - self.usercontactinfo.save() - - def enable_force_password_reset(self): - if hasattr(self, "usercontactinfo"): - self.usercontactinfo.force_password_reset = True - self.usercontactinfo.save() - - @staticmethod - def generate_full_name(user): - """Returns the first_name plus the last_name, with a space in between.""" - full_name = f"{user.first_name} {user.last_name} ({user.username})" - return full_name.strip() - - -class UserContactInfo(models.Model): - user = models.OneToOneField(Dojo_User, on_delete=models.CASCADE) - title = models.CharField(blank=True, null=True, max_length=150) - phone_regex = RegexValidator(regex=r"^\+?1?\d{9,15}$", - message=_("Phone number must be entered in the format: '+999999999'. " - "Up to 15 digits allowed.")) - phone_number = models.CharField(validators=[phone_regex], blank=True, - max_length=15, - help_text=_("Phone number must be entered in the format: '+999999999'. " - "Up to 15 digits allowed.")) - cell_number = models.CharField(validators=[phone_regex], blank=True, - max_length=15, - help_text=_("Phone number must be entered in the format: '+999999999'. " - "Up to 15 digits allowed.")) - twitter_username = models.CharField(blank=True, null=True, max_length=150) - github_username = models.CharField(blank=True, null=True, max_length=150) - slack_username = models.CharField(blank=True, null=True, max_length=150, help_text=_("Email address associated with your slack account"), verbose_name=_("Slack Email Address")) - slack_user_id = models.CharField(blank=True, null=True, max_length=25) - block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion.")) - force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) - ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) - token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) - password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) +from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401 class System_Settings(models.Model): @@ -601,15 +541,6 @@ def get_current_datetime(): return timezone.now() -class Contact(models.Model): - name = models.CharField(max_length=100) - email = models.EmailField() - team = models.CharField(max_length=100) - is_admin = models.BooleanField(default=False) - is_globally_read_only = models.BooleanField(default=False) - updated = models.DateTimeField(auto_now=True) - - class Note_Type(models.Model): name = models.CharField(max_length=100, unique=True) description = models.CharField(max_length=200) @@ -2187,7 +2118,6 @@ def __str__(self): admin.site.register(Endpoint_Params) admin.site.register(Endpoint_Status) admin.site.register(Endpoint) -admin.site.register(UserContactInfo) admin.site.register(Notes) admin.site.register(Note_Type) admin.site.register(Tool_Configuration, Tool_Configuration_Admin) @@ -2221,7 +2151,6 @@ def __str__(self): admin.site.register(Product_Type_Member) admin.site.register(Product_Type_Group) -admin.site.register(Contact) admin.site.register(NoteHistory) admin.site.register(Report_Type) admin.site.register(DojoMeta) diff --git a/dojo/urls.py b/dojo/urls.py index db1955dd439..86697752349 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -41,9 +41,6 @@ ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, - UserContactInfoViewSet, - UserProfileView, - UsersViewSet, ) from dojo.api_v2.views import DojoSpectacularAPIView as SpectacularAPIView from dojo.asset.api.urls import add_asset_urls @@ -87,7 +84,9 @@ from dojo.tool_type.urls import urlpatterns as tool_type_urls from dojo.url.api.urls import add_url_urls from dojo.url.ui.urls import urlpatterns as url_patterns -from dojo.user.urls import urlpatterns as user_urls +from dojo.user.api.urls import add_user_urls +from dojo.user.api.views import UserProfileView +from dojo.user.ui.urls import urlpatterns as user_urls from dojo.utils import get_system_setting logger = logging.getLogger(__name__) @@ -143,8 +142,7 @@ v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration") v2_api.register(r"tool_product_settings", ToolProductSettingsViewSet, basename="tool_product_settings") v2_api.register(r"tool_types", ToolTypesViewSet, basename="tool_type") -v2_api.register(r"users", UsersViewSet, basename="user") -v2_api.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") +v2_api = add_user_urls(v2_api) # Add the location routes if settings.V3_FEATURE_LOCATIONS: # Endpoints -> Locations diff --git a/dojo/user/__init__.py b/dojo/user/__init__.py index e69de29bb2d..e1885283340 100644 --- a/dojo/user/__init__.py +++ b/dojo/user/__init__.py @@ -0,0 +1 @@ +import dojo.user.admin # noqa: F401 diff --git a/dojo/user/admin.py b/dojo/user/admin.py new file mode 100644 index 00000000000..c8d20a46344 --- /dev/null +++ b/dojo/user/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from dojo.user.models import Contact, UserContactInfo + +admin.site.register(UserContactInfo) +admin.site.register(Contact) diff --git a/dojo/user/api/__init__.py b/dojo/user/api/__init__.py new file mode 100644 index 00000000000..06ffb66484b --- /dev/null +++ b/dojo/user/api/__init__.py @@ -0,0 +1 @@ +path = "users" # noqa: RUF067 diff --git a/dojo/user/api/filters.py b/dojo/user/api/filters.py new file mode 100644 index 00000000000..0e4bb8b8e14 --- /dev/null +++ b/dojo/user/api/filters.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django_filters import OrderingFilter +from django_filters import rest_framework as filters + +User = get_user_model() + + +class ApiUserFilter(filters.FilterSet): + last_login = filters.DateFromToRangeFilter() + date_joined = filters.DateFromToRangeFilter() + is_active = filters.BooleanFilter() + is_superuser = filters.BooleanFilter() + username = filters.CharFilter(lookup_expr="icontains") + first_name = filters.CharFilter(lookup_expr="icontains") + last_name = filters.CharFilter(lookup_expr="icontains") + email = filters.CharFilter(lookup_expr="icontains") + + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "email", + "is_active", + "is_superuser", + "last_login", + "date_joined", + ] + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("username", "username"), + ("last_name", "last_name"), + ("first_name", "first_name"), + ("email", "email"), + ("is_active", "is_active"), + ("is_superuser", "is_superuser"), + ("date_joined", "date_joined"), + ("last_login", "last_login"), + ), + ) diff --git a/dojo/user/api/serializer.py b/dojo/user/api/serializer.py new file mode 100644 index 00000000000..e07445b6cb9 --- /dev/null +++ b/dojo/user/api/serializer.py @@ -0,0 +1,190 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dojo.models import Dojo_User, UserContactInfo +from dojo.user.utils import get_configuration_permissions_codenames + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + date_joined = serializers.DateTimeField(read_only=True) + last_login = serializers.DateTimeField(read_only=True, allow_null=True) + email = serializers.EmailField(required=True) + token_last_reset = serializers.SerializerMethodField() + password_last_reset = serializers.SerializerMethodField() + password = serializers.CharField( + write_only=True, + style={"input_type": "password"}, + required=False, + validators=[validate_password], + ) + configuration_permissions = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Permission.objects.filter( + codename__in=get_configuration_permissions_codenames(), + ), + many=True, + required=False, + source="user_permissions", + ) + + class Meta: + model = Dojo_User + fields = ( + "id", + "username", + "first_name", + "last_name", + "email", + "date_joined", + "last_login", + "is_active", + "is_staff", + "is_superuser", + "token_last_reset", + "password_last_reset", + "password", + "configuration_permissions", + ) + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_token_last_reset(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "token_last_reset", None) + + @extend_schema_field(serializers.DateTimeField(allow_null=True)) + def get_password_last_reset(self, instance): + uci = getattr(instance, "usercontactinfo", None) + return getattr(uci, "password_last_reset", None) + + def to_representation(self, instance): + ret = super().to_representation(instance) + + # This will show only "configuration_permissions" even if user has also + # other permissions + all_permissions = set(ret["configuration_permissions"]) + allowed_configuration_permissions = set( + self.fields[ + "configuration_permissions" + ].child_relation.queryset.values_list("id", flat=True), + ) + ret["configuration_permissions"] = list( + all_permissions.intersection(allowed_configuration_permissions), + ) + + return ret + + def update(self, instance, validated_data): + permissions_in_payload = None + new_configuration_permissions = None + if ( + "user_permissions" in validated_data + ): # This field was renamed from "configuration_permissions" in the meantime + permissions_in_payload = validated_data.pop("user_permissions") + new_configuration_permissions = set(permissions_in_payload) + + instance = super().update(instance, validated_data) + + # This will update only Permissions from category + # "configuration_permissions". Others will be untouched + if new_configuration_permissions: + allowed_configuration_permissions = set( + self.fields[ + "configuration_permissions" + ].child_relation.queryset.all(), + ) + non_configuration_permissions = ( + set(instance.user_permissions.all()) + - allowed_configuration_permissions + ) + new_permissions = non_configuration_permissions.union( + new_configuration_permissions, + ) + instance.user_permissions.set(new_permissions) + + # Clear all configuration permissions if an empty list is provided + if isinstance(permissions_in_payload, list) and len(permissions_in_payload) == 0: + instance.user_permissions.clear() + + return instance + + def create(self, validated_data): + password = validated_data.pop("password", None) + + new_configuration_permissions = None + if ( + "user_permissions" in validated_data + ): # This field was renamed from "configuration_permissions" in the meantime + new_configuration_permissions = set( + validated_data.pop("user_permissions"), + ) + + user = Dojo_User.objects.create(**validated_data) + + if password: + user.set_password(password) + else: + user.set_unusable_password() + + # This will create only Permissions from category + # "configuration_permissions". There are no other Permissions. + if new_configuration_permissions: + user.user_permissions.set(new_configuration_permissions) + + user.save() + return user + + def validate(self, data): + instance_is_superuser = self.instance.is_superuser if self.instance is not None else False + data_is_superuser = data.get("is_superuser", False) + if not self.context["request"].user.is_superuser and ( + instance_is_superuser or data_is_superuser + ): + msg = "Only superusers are allowed to add or edit superusers." + raise ValidationError(msg) + + if self.context["request"].method in {"PATCH", "PUT"} and "password" in data: + msg = "Update of password though API is not allowed" + raise ValidationError(msg) + if self.context["request"].method == "POST" and "password" not in data and settings.REQUIRE_PASSWORD_ON_USER: + msg = "Passwords must be supplied for new users" + raise ValidationError(msg) + return super().validate(data) + + +class UserContactInfoSerializer(serializers.ModelSerializer): + user_profile = UserSerializer(many=False, source="user", read_only=True) + + class Meta: + model = UserContactInfo + fields = "__all__" + + def validate(self, data): + user = data.get("user", None) or self.instance.user + if data.get("force_password_reset", False) and not user.has_usable_password(): + msg = "Password resets are not allowed for users authorized through SSO." + raise ValidationError(msg) + return super().validate(data) + + +class UserStubSerializer(serializers.ModelSerializer): + class Meta: + model = Dojo_User + fields = ("id", "username", "first_name", "last_name") + + +class AddUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ("id", "username") + + +class UserProfileSerializer(serializers.Serializer): + user = UserSerializer(many=False) + user_contact_info = UserContactInfoSerializer(many=False, required=False) diff --git a/dojo/user/api/urls.py b/dojo/user/api/urls.py new file mode 100644 index 00000000000..cb8e8a909b5 --- /dev/null +++ b/dojo/user/api/urls.py @@ -0,0 +1,7 @@ +from dojo.user.api.views import UserContactInfoViewSet, UsersViewSet + + +def add_user_urls(router): + router.register(r"users", UsersViewSet, basename="user") + router.register(r"user_contact_infos", UserContactInfoViewSet, basename="usercontactinfo") + return router diff --git a/dojo/user/api/views.py b/dojo/user/api/views.py new file mode 100644 index 00000000000..c1eb3640442 --- /dev/null +++ b/dojo/user/api/views.py @@ -0,0 +1,102 @@ +import logging + +from crum import get_current_user +from django.contrib.auth import get_user_model +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema_view +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated +from rest_framework.response import Response + +from dojo.api_v2.views import DojoModelViewSet, PrefetchDojoModelViewSet, schema_with_prefetch +from dojo.authorization import api_permissions as permissions +from dojo.models import UserContactInfo +from dojo.user.api.filters import ApiUserFilter +from dojo.user.api.serializer import ( + UserContactInfoSerializer, + UserProfileSerializer, + UserSerializer, +) +from dojo.user.authentication import reset_token_for_user + +logger = logging.getLogger(__name__) + +User = get_user_model() + + +# Authorization: configuration +class UsersViewSet( + DojoModelViewSet, +): + serializer_class = UserSerializer + queryset = User.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_class = ApiUserFilter + permission_classes = (permissions.UserHasConfigurationPermissionSuperuser,) + + def get_queryset(self): + return User.objects.all().order_by("id") + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if request.user == instance: + return Response( + "Users may not delete themselves", + status=status.HTTP_400_BAD_REQUEST, + ) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + + @action( + detail=True, + methods=["post"], + url_path="reset_api_token", + permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner), + filter_backends=[], + pagination_class=None, + ) + def reset_api_token(self, request, pk=None): + target_user = self.get_object() + reset_token_for_user(acting_user=request.user, target_user=target_user) + return Response(status=status.HTTP_204_NO_CONTENT) + + +# Authorization: superuser +@extend_schema_view(**schema_with_prefetch()) +class UserContactInfoViewSet( + PrefetchDojoModelViewSet, +): + serializer_class = UserContactInfoSerializer + queryset = UserContactInfo.objects.none() + filter_backends = (DjangoFilterBackend,) + filterset_fields = "__all__" + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) + + def get_queryset(self): + return UserContactInfo.objects.all().order_by("id") + + +# Authorization: authenticated users +class UserProfileView(GenericAPIView): + permission_classes = (IsAuthenticated,) + pagination_class = None + serializer_class = UserProfileSerializer + + @action( + detail=True, methods=["get"], filter_backends=[], pagination_class=None, + ) + def get(self, request, _=None): + user = get_current_user() + user_contact_info = ( + user.usercontactinfo if hasattr(user, "usercontactinfo") else None + ) + serializer = UserProfileSerializer( + { + "user": user, + "user_contact_info": user_contact_info, + }, + many=False, + ) + return Response(serializer.data) diff --git a/dojo/user/models.py b/dojo/user/models.py new file mode 100644 index 00000000000..7d88c731fce --- /dev/null +++ b/dojo/user/models.py @@ -0,0 +1,78 @@ +from django.contrib.auth import get_user_model +from django.core.validators import RegexValidator +from django.db import models +from django.utils.translation import gettext as _ + +User = get_user_model() + + +# proxy class for convenience and UI +class Dojo_User(User): + class Meta: + proxy = True + ordering = ["first_name"] + + def get_full_name(self): + return Dojo_User.generate_full_name(self) + + def __str__(self): + return self.get_full_name() + + @staticmethod + def wants_block_execution(user): + # this return False if there is no user, i.e. in celery processes, unittests, etc. + return hasattr(user, "usercontactinfo") and user.usercontactinfo.block_execution + + @staticmethod + def force_password_reset(user): + return hasattr(user, "usercontactinfo") and user.usercontactinfo.force_password_reset + + def disable_force_password_reset(self): + if hasattr(self, "usercontactinfo"): + self.usercontactinfo.force_password_reset = False + self.usercontactinfo.save() + + def enable_force_password_reset(self): + if hasattr(self, "usercontactinfo"): + self.usercontactinfo.force_password_reset = True + self.usercontactinfo.save() + + @staticmethod + def generate_full_name(user): + """Returns the first_name plus the last_name, with a space in between.""" + full_name = f"{user.first_name} {user.last_name} ({user.username})" + return full_name.strip() + + +class UserContactInfo(models.Model): + user = models.OneToOneField("dojo.Dojo_User", on_delete=models.CASCADE) + title = models.CharField(blank=True, null=True, max_length=150) + phone_regex = RegexValidator(regex=r"^\+?1?\d{9,15}$", + message=_("Phone number must be entered in the format: '+999999999'. " + "Up to 15 digits allowed.")) + phone_number = models.CharField(validators=[phone_regex], blank=True, + max_length=15, + help_text=_("Phone number must be entered in the format: '+999999999'. " + "Up to 15 digits allowed.")) + cell_number = models.CharField(validators=[phone_regex], blank=True, + max_length=15, + help_text=_("Phone number must be entered in the format: '+999999999'. " + "Up to 15 digits allowed.")) + twitter_username = models.CharField(blank=True, null=True, max_length=150) + github_username = models.CharField(blank=True, null=True, max_length=150) + slack_username = models.CharField(blank=True, null=True, max_length=150, help_text=_("Email address associated with your slack account"), verbose_name=_("Slack Email Address")) + slack_user_id = models.CharField(blank=True, null=True, max_length=25) + block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion.")) + force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login.")) + ui_use_tailwind = models.BooleanField(default=False, verbose_name=_("Use new UI (beta)"), help_text=_("Opt in to the new Tailwind-based UI. Leave off for the classic UI.")) + token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user.")) + password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user.")) + + +class Contact(models.Model): + name = models.CharField(max_length=100) + email = models.EmailField() + team = models.CharField(max_length=100) + is_admin = models.BooleanField(default=False) + is_globally_read_only = models.BooleanField(default=False) + updated = models.DateTimeField(auto_now=True) diff --git a/dojo/user/ui/__init__.py b/dojo/user/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/user/ui/filters.py b/dojo/user/ui/filters.py new file mode 100644 index 00000000000..91ab303f69f --- /dev/null +++ b/dojo/user/ui/filters.py @@ -0,0 +1,36 @@ +from django_filters import CharFilter, OrderingFilter + +from dojo.filters import DojoFilter +from dojo.models import Dojo_User + + +class UserFilter(DojoFilter): + first_name = CharFilter(lookup_expr="icontains") + last_name = CharFilter(lookup_expr="icontains") + username = CharFilter(lookup_expr="icontains") + email = CharFilter(lookup_expr="icontains") + + o = OrderingFilter( + # tuple-mapping retains order + fields=( + ("username", "username"), + ("last_name", "last_name"), + ("first_name", "first_name"), + ("email", "email"), + ("is_active", "is_active"), + ("is_superuser", "is_superuser"), + ("is_staff", "is_staff"), + ("date_joined", "date_joined"), + ("last_login", "last_login"), + ), + field_labels={ + "username": "User Name", + "is_active": "Active", + "is_superuser": "Superuser", + "is_staff": "Staff", + }, + ) + + class Meta: + model = Dojo_User + fields = ["is_superuser", "is_staff", "is_active", "first_name", "last_name", "username", "email"] diff --git a/dojo/user/ui/forms.py b/dojo/user/ui/forms.py new file mode 100644 index 00000000000..d32c3da187e --- /dev/null +++ b/dojo/user/ui/forms.py @@ -0,0 +1,114 @@ +from crum import get_current_user +from django import forms +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.utils.translation import gettext_lazy as _ + +from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner +from dojo.models import Dojo_User, User, UserContactInfo +from dojo.utils import get_password_requirements_string, get_system_setting + + +class DojoUserForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not get_current_user().is_superuser and not get_system_setting("enable_user_profile_editable"): + for field in self.fields: + self.fields[field].disabled = True + + class Meta: + model = Dojo_User + exclude = ["password", "last_login", "is_superuser", "groups", + "username", "is_staff", "is_active", "date_joined", + "user_permissions"] + + +class AddDojoUserForm(forms.ModelForm): + email = forms.EmailField(required=True) + password = forms.CharField(widget=forms.PasswordInput, + required=settings.REQUIRE_PASSWORD_ON_USER, + validators=[validate_password], + help_text="") + + class Meta: + model = Dojo_User + fields = ["username", "password", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + current_user = get_current_user() + if not current_user.is_superuser: + self.fields["is_staff"].disabled = True + self.fields["is_superuser"].disabled = True + self.fields["password"].help_text = get_password_requirements_string() + + +class EditDojoUserForm(forms.ModelForm): + email = forms.EmailField(required=True) + + class Meta: + model = Dojo_User + fields = ["username", "first_name", "last_name", "email", "is_active", "is_staff", "is_superuser"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + current_user = get_current_user() + if not current_user.is_superuser: + self.fields["is_staff"].disabled = True + self.fields["is_superuser"].disabled = True + + +class DeleteUserForm(forms.ModelForm): + id = forms.IntegerField(required=True, + widget=forms.widgets.HiddenInput()) + + class Meta: + model = User + fields = ["id"] + + +class UserContactInfoForm(forms.ModelForm): + reset_api_token = forms.BooleanField( + required=False, + label=_("Reset API token"), + help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."), + ) + + class Meta: + model = UserContactInfo + exclude = ["user", "slack_user_id"] + # Swap order: password_last_reset before token_last_reset + field_order = [ + "title", "phone_number", "cell_number", "twitter_username", "github_username", + "slack_username", "ui_use_tailwind", "block_execution", "force_password_reset", "reset_api_token", + "password_last_reset", "token_last_reset", + ] + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user", None) + super().__init__(*args, **kwargs) + # Make timestamp fields readonly. + # NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields + # are ignored during binding/cleaning, so these timestamps cannot be modified via this form. + if "password_last_reset" in self.fields: + self.fields["password_last_reset"].disabled = True + if "token_last_reset" in self.fields: + self.fields["token_last_reset"].disabled = True + # Do not expose force password reset if the current user does not have a password to reset + if user is not None: + if not user.has_usable_password(): + self.fields["force_password_reset"].disabled = True + self.fields["force_password_reset"].help_text = "This user is authorized through SSO, and does not have a password to reset" + # Determine some other settings based on the current user + current_user = get_current_user() + if not current_user.is_superuser: + if not user_has_configuration_permission(current_user, "auth.change_user") and \ + not user_has_configuration_permission(current_user, "auth.add_user"): + self.fields.pop("force_password_reset", None) + if not get_system_setting("enable_user_profile_editable"): + for field in self.fields: + self.fields[field].disabled = True + + # Only show reset_api_token to superusers or global owners, and only if API tokens are enabled + if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user): + self.fields.pop("reset_api_token", None) diff --git a/dojo/user/urls.py b/dojo/user/ui/urls.py similarity index 99% rename from dojo/user/urls.py rename to dojo/user/ui/urls.py index b3c97bea8ea..395954f7679 100644 --- a/dojo/user/urls.py +++ b/dojo/user/ui/urls.py @@ -2,7 +2,7 @@ from django.contrib.auth import views as auth_views from django.urls import re_path, reverse_lazy -from dojo.user import views +from dojo.user.ui import views urlpatterns = [ # user specific diff --git a/dojo/user/views.py b/dojo/user/ui/views.py similarity index 99% rename from dojo/user/views.py rename to dojo/user/ui/views.py index ce3d8449a39..9ba12c044c8 100644 --- a/dojo/user/views.py +++ b/dojo/user/ui/views.py @@ -29,22 +29,24 @@ from dojo.authorization.authorization import user_is_superuser_or_global_owner from dojo.decorators import dojo_ratelimit -from dojo.filters import UserFilter from dojo.forms import ( - AddDojoUserForm, APIKeyForm, Authorize_User_For_ProductsForm, Authorize_User_For_ProductTypesForm, ChangePasswordForm, ConfigurationPermissionsForm, +) +from dojo.labels import get_labels +from dojo.models import Alerts, Dojo_User, Product, Product_Type, UserContactInfo +from dojo.user.authentication import reset_token_for_user +from dojo.user.ui.filters import UserFilter +from dojo.user.ui.forms import ( + AddDojoUserForm, DeleteUserForm, DojoUserForm, EditDojoUserForm, UserContactInfoForm, ) -from dojo.labels import get_labels -from dojo.models import Alerts, Dojo_User, Product, Product_Type, UserContactInfo -from dojo.user.authentication import reset_token_for_user from dojo.utils import add_breadcrumb, get_page_items, get_setting, get_system_setting logger = logging.getLogger(__name__) diff --git a/unittests/test_rest_framework.py b/unittests/test_rest_framework.py index 6a455168670..81cb43bac98 100644 --- a/unittests/test_rest_framework.py +++ b/unittests/test_rest_framework.py @@ -57,8 +57,6 @@ ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, - UserContactInfoViewSet, - UsersViewSet, ) from dojo.asset.api.views import ( AssetAPIScanConfigurationViewSet, @@ -118,6 +116,7 @@ from dojo.test.api.views import TestsViewSet, TestTypesViewSet from dojo.url.api.views import URLViewSet from dojo.url.models import URL +from dojo.user.api.views import UserContactInfoViewSet, UsersViewSet from .dojo_test_case import ( DojoAPITestCase, diff --git a/unittests/test_user_ui_timestamps.py b/unittests/test_user_ui_timestamps.py index e2296368d14..ad2b7ebe145 100644 --- a/unittests/test_user_ui_timestamps.py +++ b/unittests/test_user_ui_timestamps.py @@ -49,7 +49,7 @@ def test_change_password_stamps_password_last_reset(self): user.save() self.client.force_login(user) - with patch("dojo.user.views.now", return_value=fixed): + with patch("dojo.user.ui.views.now", return_value=fixed): resp = self.client.post( reverse("change_password"), data={ @@ -74,7 +74,7 @@ def test_password_reset_confirm_stamps_password_last_reset(self): token = default_token_generator.make_token(user) url = reverse("password_reset_confirm", kwargs={"uidb64": uidb64, "token": token}) - with patch("dojo.user.views.now", return_value=fixed): + with patch("dojo.user.ui.views.now", return_value=fixed): # Django's PasswordResetConfirmView typically requires a GET to the tokenized URL, # which sets a session token and redirects to the "set-password" URL. resp_get = self.client.get(url) From 25aaaed0c156dea98b66bae30184de9093c9bf08 Mon Sep 17 00:00:00 2001 From: Valentijn Scholten Date: Mon, 8 Jun 2026 23:20:54 +0200 Subject: [PATCH 2/2] refactor(system_settings): extract System_Settings into dojo/system_settings/ [system_settings Phase 1,3,5,6,8,9] --- dojo/api_v2/serializers.py | 6 +- dojo/api_v2/views.py | 15 - dojo/forms.py | 36 --- dojo/models.py | 368 +------------------------ dojo/system_settings/__init__.py | 1 + dojo/system_settings/admin.py | 5 + dojo/system_settings/api/__init__.py | 1 + dojo/system_settings/api/serializer.py | 9 + dojo/system_settings/api/urls.py | 7 + dojo/system_settings/api/views.py | 21 ++ dojo/system_settings/models.py | 365 ++++++++++++++++++++++++ dojo/system_settings/ui/__init__.py | 0 dojo/system_settings/ui/forms.py | 41 +++ dojo/system_settings/{ => ui}/urls.py | 2 +- dojo/system_settings/{ => ui}/views.py | 4 +- dojo/urls.py | 6 +- 16 files changed, 461 insertions(+), 426 deletions(-) create mode 100644 dojo/system_settings/admin.py create mode 100644 dojo/system_settings/api/__init__.py create mode 100644 dojo/system_settings/api/serializer.py create mode 100644 dojo/system_settings/api/urls.py create mode 100644 dojo/system_settings/api/views.py create mode 100644 dojo/system_settings/models.py create mode 100644 dojo/system_settings/ui/__init__.py create mode 100644 dojo/system_settings/ui/forms.py rename dojo/system_settings/{ => ui}/urls.py (87%) rename dojo/system_settings/{ => ui}/views.py (97%) diff --git a/dojo/api_v2/serializers.py b/dojo/api_v2/serializers.py index 4f8794fcb5c..be48838afe0 100644 --- a/dojo/api_v2/serializers.py +++ b/dojo/api_v2/serializers.py @@ -56,7 +56,6 @@ SLA_Configuration, Sonarqube_Issue, Sonarqube_Issue_Transition, - System_Settings, Test, Tool_Configuration, Tool_Product_Settings, @@ -1459,10 +1458,7 @@ class TagSerializer(serializers.Serializer): tags = TagListSerializerField(required=True) -class SystemSettingsSerializer(serializers.ModelSerializer): - class Meta: - model = System_Settings - fields = "__all__" +from dojo.system_settings.api.serializer import SystemSettingsSerializer # noqa: E402, F401 -- backward compat class CeleryStatusSerializer(serializers.Serializer): diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 2e6d706ee27..50ddd53a4d5 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -1188,21 +1188,6 @@ def report_generate(request, obj, options): return result -# Authorization: superuser -class SystemSettingsViewSet( - mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet, -): - - """Basic control over System Settings. Use 'id' 1 for PUT, PATCH operations""" - - permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) - serializer_class = serializers.SystemSettingsSerializer - queryset = System_Settings.objects.none() - - def get_queryset(self): - return System_Settings.objects.all().order_by("id") - - class CeleryViewSet(viewsets.ViewSet): permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) queryset = System_Settings.objects.none() diff --git a/dojo/forms.py b/dojo/forms.py index 5a0a9828392..57ce9e0f53b 100644 --- a/dojo/forms.py +++ b/dojo/forms.py @@ -83,7 +83,6 @@ Regulation, Risk_Acceptance, SLA_Configuration, - System_Settings, Test_Type, TextAnswer, TextQuestion, @@ -1296,41 +1295,6 @@ def clean(self): return self.cleaned_data -class SystemSettingsForm(forms.ModelForm): - jira_webhook_secret = forms.CharField(required=False) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields["enable_product_tracking_files"].label = labels.SETTINGS_TRACKED_FILES_ENABLE_LABEL - self.fields["enable_product_tracking_files"].help_text = labels.SETTINGS_TRACKED_FILES_ENABLE_HELP - - self.fields[ - "enforce_verified_status_product_grading"].label = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_LABEL - self.fields[ - "enforce_verified_status_product_grading"].help_text = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_HELP - - self.fields["enable_product_grade"].label = labels.SETTINGS_ASSET_GRADING_ENABLE_LABEL - self.fields["enable_product_grade"].help_text = labels.SETTINGS_ASSET_GRADING_ENABLE_HELP - - self.fields["enable_product_tag_inheritance"].label = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_LABEL - self.fields["enable_product_tag_inheritance"].help_text = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_HELP - - def clean(self): - cleaned_data = super().clean() - enable_jira_value = cleaned_data.get("enable_jira") - jira_webhook_secret_value = cleaned_data.get("jira_webhook_secret").strip() - - if enable_jira_value and not jira_webhook_secret_value: - self.add_error("jira_webhook_secret", "This field is required when enable Jira Integration is True") - - return cleaned_data - - class Meta: - model = System_Settings - exclude = () - - class BenchmarkForm(forms.ModelForm): class Meta: diff --git a/dojo/models.py b/dojo/models.py index 09f4bfd2c84..bec3b2ec4f9 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -16,7 +16,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.core.validators import MaxValueValidator, MinValueValidator, validate_ipv46_address +from django.core.validators import validate_ipv46_address from django.db import connection, models from django.db.models import Count, F, Q from django.db.models.expressions import Case, When @@ -169,368 +169,8 @@ def __str__(self): User = get_user_model() -from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401 - - -class System_Settings(models.Model): - enable_deduplication = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Deduplicate findings"), - help_text=_("With this setting turned on, DefectDojo deduplicates findings by " - "comparing endpoints, cwe fields, and titles. " - "If two findings share a URL and have the same CWE or " - "title, DefectDojo marks the recent finding as a duplicate. " - "When deduplication is enabled, a list of " - "deduplicated findings is added to the engagement view.")) - delete_duplicates = models.BooleanField(default=False, blank=False, help_text=_("Requires next setting: maximum number of duplicates to retain.")) - max_dupes = models.IntegerField(blank=True, null=True, default=10, - verbose_name=_("Max Duplicates"), - help_text=_("When enabled, if a single " - "issue reaches the maximum " - "number of duplicates, the " - "oldest will be deleted. Duplicate will not be deleted when left empty. A value of 0 will remove all duplicates.")) - - email_from = models.CharField(max_length=200, default="no-reply@example.com", blank=True) - - enable_jira = models.BooleanField(default=False, - verbose_name=_("Enable JIRA integration"), - blank=False) - - enable_jira_web_hook = models.BooleanField(default=False, - verbose_name=_("Enable JIRA web hook"), - help_text=_("Please note: It is strongly recommended to use a secret below and / or IP whitelist the JIRA server using a proxy such as Nginx."), - blank=False) - - disable_jira_webhook_secret = models.BooleanField(default=False, - verbose_name=_("Disable web hook secret"), - help_text=_("Allows incoming requests without a secret (discouraged legacy behaviour)"), - blank=False) - - # will be set to random / uuid by initializer so null needs to be True - jira_webhook_secret = models.CharField(max_length=64, blank=False, null=True, verbose_name=_("JIRA Webhook URL"), - help_text=_("Secret needed in URL for incoming JIRA Webhook")) - - jira_choices = (("Critical", "Critical"), - ("High", "High"), - ("Medium", "Medium"), - ("Low", "Low"), - ("Info", "Info")) - jira_minimum_severity = models.CharField(max_length=20, blank=True, - null=True, choices=jira_choices, - default="Low") - jira_labels = models.CharField(max_length=200, blank=True, null=True, - help_text=_("JIRA issue labels space seperated")) - - add_vulnerability_id_to_jira_label = models.BooleanField(default=False, - verbose_name=_("Add vulnerability Id as a JIRA label"), - blank=False) - - enable_github = models.BooleanField(default=False, - verbose_name=_("Enable GITHUB integration"), - blank=False) - - enable_slack_notifications = \ - models.BooleanField(default=False, - verbose_name=_("Enable Slack notifications"), - blank=False) - slack_channel = models.CharField(max_length=100, default="", blank=True, - help_text=_("Optional. Needed if you want to send global notifications.")) - slack_token = models.CharField(max_length=100, default="", blank=True, - help_text=_("Token required for interacting " - "with Slack. Get one at " - "https://api.slack.com/tokens")) - slack_username = models.CharField(max_length=100, default="", blank=True, - help_text=_("Optional. Will take your bot name otherwise.")) - enable_msteams_notifications = \ - models.BooleanField(default=False, - verbose_name=_("Enable Microsoft Teams notifications"), - blank=False) - msteams_url = models.CharField(max_length=400, default="", blank=True, - help_text=_("The full URL of the " - "incoming webhook")) - enable_mail_notifications = models.BooleanField(default=False, blank=False) - mail_notifications_to = models.CharField(max_length=200, default="", - blank=True) - - enable_webhooks_notifications = \ - models.BooleanField(default=False, - verbose_name=_("Enable Webhook notifications"), - blank=False) - webhooks_notifications_timeout = models.IntegerField(default=10, - help_text=_("How many seconds will DefectDojo waits for response from webhook endpoint")) - - enforce_verified_status = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Globally"), - help_text=_( - "When enabled, features such as product grading, jira " - "integration, metrics, and reports will only interact " - "with verified findings. This setting will override " - "individually scoped verified toggles.", - ), - ) - enforce_verified_status_jira = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Jira"), - help_text=_("When enabled, findings must have a verified status to be pushed to jira."), - ) - enforce_verified_status_product_grading = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Product Grading"), - help_text=_( - "When enabled, findings must have a verified status to be considered as part of a product's grading.", - ), - ) - enforce_verified_status_metrics = models.BooleanField( - default=True, - verbose_name=_("Enforce Verified Status - Metrics"), - help_text=_( - "When enabled, findings must have a verified status to be counted in metric calculations, " - "be included in reports, and filters.", - ), - ) - - false_positive_history = models.BooleanField( - default=False, help_text=_( - "(EXPERIMENTAL) DefectDojo will automatically mark the finding as a " - "false positive if an equal finding (according to its dedupe algorithm) " - "has been previously marked as a false positive on the same product. " - "ATTENTION: Although the deduplication algorithm is used to determine " - "if a finding should be marked as a false positive, this feature will " - "not work if deduplication is enabled since it doesn't make sense to use both.", - ), - ) - - retroactive_false_positive_history = models.BooleanField( - default=False, help_text=_( - "(EXPERIMENTAL) FP History will also retroactively mark/unmark all " - "existing equal findings in the same product as a false positives. " - "Only works if the False Positive History feature is also enabled.", - ), - ) - - url_prefix = models.CharField(max_length=300, default="", blank=True, help_text=_("URL prefix if DefectDojo is installed in it's own virtual subdirectory.")) - team_name = models.CharField(max_length=100, default="", blank=True) - enable_product_grade = models.BooleanField(default=False, verbose_name=_("Enable Product Grading"), help_text=_("Displays a grade letter next to a product to show the overall health.")) - product_grade_a = models.IntegerField(default=90, - verbose_name=_("Grade A"), - help_text=_("Percentage score for an " - "'A' >=")) - product_grade_b = models.IntegerField(default=80, - verbose_name=_("Grade B"), - help_text=_("Percentage score for a " - "'B' >=")) - product_grade_c = models.IntegerField(default=70, - verbose_name=_("Grade C"), - help_text=_("Percentage score for a " - "'C' >=")) - product_grade_d = models.IntegerField(default=60, - verbose_name=_("Grade D"), - help_text=_("Percentage score for a " - "'D' >=")) - product_grade_f = models.IntegerField(default=59, - verbose_name=_("Grade F"), - help_text=_("Percentage score for an " - "'F' <=")) - enable_product_tag_inheritance = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Product Tag Inheritance"), - help_text=_("Enables product tag inheritance globally for all products. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings")) - - enable_benchmark = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Benchmarks"), - help_text=_("Enables Benchmarks such as the OWASP ASVS " - "(Application Security Verification Standard)")) - - enable_similar_findings = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Similar Findings"), - help_text=_("Enable the query of similar findings on the view finding page. This feature can involve potentially large queries and negatively impact performance")) - - engagement_auto_close = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Engagement Auto-Close"), - help_text=_("Closes an engagement after 3 days (default) past due date including last update.")) - - engagement_auto_close_days = models.IntegerField( - default=3, - blank=False, - verbose_name=_("Engagement Auto-Close Days"), - help_text=_("Closes an engagement after the specified number of days past due date including last update.")) - - enable_finding_sla = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Finding SLA's"), - help_text=_("Enables Finding SLA's for time to remediate.")) - - enable_notify_sla_active = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Notify SLA's Breach for active Findings"), - help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active Findings.")) - - enable_notify_sla_active_verified = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Notify SLA's Breach for active, verified Findings"), - help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active, verified Findings.")) - - enable_notify_sla_jira_only = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable Notify SLA's Breach only for Findings linked to JIRA"), - help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for Findings that are linked to JIRA issues. Notification is disabled for Findings not linked to JIRA issues")) - - enable_notify_sla_exponential_backoff = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Enable an exponential backoff strategy for SLA breach notifications."), - help_text=_("Enable an exponential backoff strategy for SLA breach notifications, e.g. 1, 2, 4, 8, etc. Otherwise it alerts every day")) - - allow_anonymous_survey_repsonse = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Allow Anonymous Survey Responses"), - help_text=_("Enable anyone with a link to the survey to answer a survey"), - ) - disclaimer_notifications = models.TextField(max_length=3000, default="", blank=True, - verbose_name=_("Custom Disclaimer for Notifications"), - help_text=_("Include this custom disclaimer on all notifications")) - disclaimer_reports = models.TextField(max_length=5000, default="", blank=True, - verbose_name=_("Custom Disclaimer for Reports"), - help_text=_("Include this custom disclaimer on generated reports")) - disclaimer_reports_forced = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Force to add disclaimer reports"), - help_text=_("Disclaimer will be added to all reports even if user didn't selected 'Include disclaimer'.")) - disclaimer_notes = models.TextField(max_length=3000, default="", blank=True, - verbose_name=_("Custom Disclaimer for Notes"), - help_text=_("Include this custom disclaimer next to input form for notes")) - risk_acceptance_form_default_days = models.IntegerField(null=True, blank=True, default=180, help_text=_("Default expiry period for risk acceptance form.")) - risk_acceptance_notify_before_expiration = models.IntegerField(null=True, blank=True, default=10, - verbose_name=_("Risk acceptance expiration heads up days"), help_text=_("Notify X days before risk acceptance expires. Leave empty to disable.")) - enable_questionnaires = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable questionnaires"), - help_text=_("With this setting turned off, questionnaires will be disabled in the user interface.")) - enable_checklists = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable checklists"), - help_text=_("With this setting turned off, checklists will be disabled in the user interface.")) - enable_endpoint_metadata_import = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Endpoint Metadata Import"), - help_text=_("With this setting turned off, endpoint metadata import will be disabled in the user interface.")) - enable_user_profile_editable = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable user profile for writing"), - help_text=_("When turned on users can edit their profiles")) - enable_product_tracking_files = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Product Tracking Files"), - help_text=_("With this setting turned off, the product tracking files will be disabled in the user interface.")) - enable_finding_groups = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Finding Groups"), - help_text=_("With this setting turned off, the Finding Groups will be disabled.")) - enable_ui_table_based_searching = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable UI Table Based Filtering/Sorting"), - help_text=_("With this setting enabled, table headings will contain sort buttons for the current page of data in addition to sorting buttons that consider data from all pages.")) - enable_calendar = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable Calendar"), - help_text=_("With this setting turned off, the Calendar will be disabled in the user interface.")) - enable_cvss3_display = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable CVSS3 Display"), - help_text=_("With this setting turned off, CVSS3 fields will be hidden in the user interface.")) - enable_cvss4_display = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Enable CVSS4 Display"), - help_text=_("With this setting turned off, CVSS4 fields will be hidden in the user interface.")) - minimum_password_length = models.IntegerField( - default=9, - verbose_name=_("Minimum password length"), - help_text=_("Requires user to set passwords greater than minimum length."), - validators=[MinValueValidator(9), MaxValueValidator(48)]) - maximum_password_length = models.IntegerField( - default=48, - verbose_name=_("Maximum password length"), - help_text=_("Requires user to set passwords less than maximum length."), - validators=[MinValueValidator(9), MaxValueValidator(48)]) - number_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one digit"), - help_text=_("Requires user passwords to contain at least one digit (0-9).")) - special_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one special character"), - help_text=_("Requires user passwords to contain at least one special character (()[]{}|\\`~!@#$%^&*_-+=;:'\",<>./?).")) - lowercase_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one lowercase letter"), - help_text=_("Requires user passwords to contain at least one lowercase letter (a-z).")) - uppercase_character_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must contain one uppercase letter"), - help_text=_("Requires user passwords to contain at least one uppercase letter (A-Z).")) - non_common_password_required = models.BooleanField( - default=True, - blank=False, - verbose_name=_("Password must not be common"), - help_text=_("Requires user passwords to not be part of list of common passwords.")) - api_expose_error_details = models.BooleanField( - default=False, - blank=False, - verbose_name=_("API expose error details"), - help_text=_("When turned on, the API will expose error details in the response.")) - filter_string_matching = models.BooleanField( - default=False, - blank=False, - verbose_name=_("Filter String Matching Optimization"), - help_text=_( - "When turned on, all filter operations in the UI will require string matches rather than ID. " - "This is a performance enhancement to avoid fetching objects unnecessarily.", - )) - - from dojo.middleware import System_Settings_Manager # noqa: PLC0415 circular import - objects = System_Settings_Manager() - - def clean(self): - super().clean() - - if ( - self.minimum_password_length is not None - and self.maximum_password_length is not None - ): - if self.minimum_password_length > self.maximum_password_length: - msg = "Minimum required password length must be larger than the maximum required password length." - raise ValidationError({ - "minimum_password_length": msg, - }) +from dojo.user.models import Contact, Dojo_User, UserContactInfo # noqa: E402, F401, I001 -- must precede system_settings (middleware load-order) +from dojo.system_settings.models import System_Settings # noqa: E402, F401 -- re-export def get_current_date(): @@ -2123,7 +1763,7 @@ def __str__(self): admin.site.register(Tool_Configuration, Tool_Configuration_Admin) admin.site.register(Tool_Product_Settings) admin.site.register(Tool_Type) -admin.site.register(System_Settings) + admin.site.register(SLA_Configuration) admin.site.register(Regulation) from dojo.authorization.models import ( # noqa: E402 diff --git a/dojo/system_settings/__init__.py b/dojo/system_settings/__init__.py index e69de29bb2d..50305d372ec 100644 --- a/dojo/system_settings/__init__.py +++ b/dojo/system_settings/__init__.py @@ -0,0 +1 @@ +import dojo.system_settings.admin # noqa: F401 diff --git a/dojo/system_settings/admin.py b/dojo/system_settings/admin.py new file mode 100644 index 00000000000..6a06a94bbf1 --- /dev/null +++ b/dojo/system_settings/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from dojo.system_settings.models import System_Settings + +admin.site.register(System_Settings) diff --git a/dojo/system_settings/api/__init__.py b/dojo/system_settings/api/__init__.py new file mode 100644 index 00000000000..d8f9bbde95a --- /dev/null +++ b/dojo/system_settings/api/__init__.py @@ -0,0 +1 @@ +path = "system_settings" # noqa: RUF067 diff --git a/dojo/system_settings/api/serializer.py b/dojo/system_settings/api/serializer.py new file mode 100644 index 00000000000..c58fe7549b4 --- /dev/null +++ b/dojo/system_settings/api/serializer.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from dojo.system_settings.models import System_Settings + + +class SystemSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = System_Settings + fields = "__all__" diff --git a/dojo/system_settings/api/urls.py b/dojo/system_settings/api/urls.py new file mode 100644 index 00000000000..d22f65e5dbe --- /dev/null +++ b/dojo/system_settings/api/urls.py @@ -0,0 +1,7 @@ +from dojo.system_settings.api import path +from dojo.system_settings.api.views import SystemSettingsViewSet + + +def add_system_settings_urls(router): + router.register(path, SystemSettingsViewSet, basename="system_settings") + return router diff --git a/dojo/system_settings/api/views.py b/dojo/system_settings/api/views.py new file mode 100644 index 00000000000..3e3f6d90ec9 --- /dev/null +++ b/dojo/system_settings/api/views.py @@ -0,0 +1,21 @@ +from rest_framework import mixins, viewsets +from rest_framework.permissions import DjangoModelPermissions + +from dojo.authorization import api_permissions as permissions +from dojo.system_settings.api.serializer import SystemSettingsSerializer +from dojo.system_settings.models import System_Settings + + +# Authorization: superuser +class SystemSettingsViewSet( + mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet, +): + + """Basic control over System Settings. Use 'id' 1 for PUT, PATCH operations""" + + permission_classes = (permissions.IsSuperUser, DjangoModelPermissions) + serializer_class = SystemSettingsSerializer + queryset = System_Settings.objects.none() + + def get_queryset(self): + return System_Settings.objects.all().order_by("id") diff --git a/dojo/system_settings/models.py b/dojo/system_settings/models.py new file mode 100644 index 00000000000..81024a58383 --- /dev/null +++ b/dojo/system_settings/models.py @@ -0,0 +1,365 @@ +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.utils.translation import gettext as _ + + +class System_Settings(models.Model): + enable_deduplication = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Deduplicate findings"), + help_text=_("With this setting turned on, DefectDojo deduplicates findings by " + "comparing endpoints, cwe fields, and titles. " + "If two findings share a URL and have the same CWE or " + "title, DefectDojo marks the recent finding as a duplicate. " + "When deduplication is enabled, a list of " + "deduplicated findings is added to the engagement view.")) + delete_duplicates = models.BooleanField(default=False, blank=False, help_text=_("Requires next setting: maximum number of duplicates to retain.")) + max_dupes = models.IntegerField(blank=True, null=True, default=10, + verbose_name=_("Max Duplicates"), + help_text=_("When enabled, if a single " + "issue reaches the maximum " + "number of duplicates, the " + "oldest will be deleted. Duplicate will not be deleted when left empty. A value of 0 will remove all duplicates.")) + + email_from = models.CharField(max_length=200, default="no-reply@example.com", blank=True) + + enable_jira = models.BooleanField(default=False, + verbose_name=_("Enable JIRA integration"), + blank=False) + + enable_jira_web_hook = models.BooleanField(default=False, + verbose_name=_("Enable JIRA web hook"), + help_text=_("Please note: It is strongly recommended to use a secret below and / or IP whitelist the JIRA server using a proxy such as Nginx."), + blank=False) + + disable_jira_webhook_secret = models.BooleanField(default=False, + verbose_name=_("Disable web hook secret"), + help_text=_("Allows incoming requests without a secret (discouraged legacy behaviour)"), + blank=False) + + # will be set to random / uuid by initializer so null needs to be True + jira_webhook_secret = models.CharField(max_length=64, blank=False, null=True, verbose_name=_("JIRA Webhook URL"), + help_text=_("Secret needed in URL for incoming JIRA Webhook")) + + jira_choices = (("Critical", "Critical"), + ("High", "High"), + ("Medium", "Medium"), + ("Low", "Low"), + ("Info", "Info")) + jira_minimum_severity = models.CharField(max_length=20, blank=True, + null=True, choices=jira_choices, + default="Low") + jira_labels = models.CharField(max_length=200, blank=True, null=True, + help_text=_("JIRA issue labels space seperated")) + + add_vulnerability_id_to_jira_label = models.BooleanField(default=False, + verbose_name=_("Add vulnerability Id as a JIRA label"), + blank=False) + + enable_github = models.BooleanField(default=False, + verbose_name=_("Enable GITHUB integration"), + blank=False) + + enable_slack_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Slack notifications"), + blank=False) + slack_channel = models.CharField(max_length=100, default="", blank=True, + help_text=_("Optional. Needed if you want to send global notifications.")) + slack_token = models.CharField(max_length=100, default="", blank=True, + help_text=_("Token required for interacting " + "with Slack. Get one at " + "https://api.slack.com/tokens")) + slack_username = models.CharField(max_length=100, default="", blank=True, + help_text=_("Optional. Will take your bot name otherwise.")) + enable_msteams_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Microsoft Teams notifications"), + blank=False) + msteams_url = models.CharField(max_length=400, default="", blank=True, + help_text=_("The full URL of the " + "incoming webhook")) + enable_mail_notifications = models.BooleanField(default=False, blank=False) + mail_notifications_to = models.CharField(max_length=200, default="", + blank=True) + + enable_webhooks_notifications = \ + models.BooleanField(default=False, + verbose_name=_("Enable Webhook notifications"), + blank=False) + webhooks_notifications_timeout = models.IntegerField(default=10, + help_text=_("How many seconds will DefectDojo waits for response from webhook endpoint")) + + enforce_verified_status = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Globally"), + help_text=_( + "When enabled, features such as product grading, jira " + "integration, metrics, and reports will only interact " + "with verified findings. This setting will override " + "individually scoped verified toggles.", + ), + ) + enforce_verified_status_jira = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Jira"), + help_text=_("When enabled, findings must have a verified status to be pushed to jira."), + ) + enforce_verified_status_product_grading = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Product Grading"), + help_text=_( + "When enabled, findings must have a verified status to be considered as part of a product's grading.", + ), + ) + enforce_verified_status_metrics = models.BooleanField( + default=True, + verbose_name=_("Enforce Verified Status - Metrics"), + help_text=_( + "When enabled, findings must have a verified status to be counted in metric calculations, " + "be included in reports, and filters.", + ), + ) + + false_positive_history = models.BooleanField( + default=False, help_text=_( + "(EXPERIMENTAL) DefectDojo will automatically mark the finding as a " + "false positive if an equal finding (according to its dedupe algorithm) " + "has been previously marked as a false positive on the same product. " + "ATTENTION: Although the deduplication algorithm is used to determine " + "if a finding should be marked as a false positive, this feature will " + "not work if deduplication is enabled since it doesn't make sense to use both.", + ), + ) + + retroactive_false_positive_history = models.BooleanField( + default=False, help_text=_( + "(EXPERIMENTAL) FP History will also retroactively mark/unmark all " + "existing equal findings in the same product as a false positives. " + "Only works if the False Positive History feature is also enabled.", + ), + ) + + url_prefix = models.CharField(max_length=300, default="", blank=True, help_text=_("URL prefix if DefectDojo is installed in it's own virtual subdirectory.")) + team_name = models.CharField(max_length=100, default="", blank=True) + enable_product_grade = models.BooleanField(default=False, verbose_name=_("Enable Product Grading"), help_text=_("Displays a grade letter next to a product to show the overall health.")) + product_grade_a = models.IntegerField(default=90, + verbose_name=_("Grade A"), + help_text=_("Percentage score for an " + "'A' >=")) + product_grade_b = models.IntegerField(default=80, + verbose_name=_("Grade B"), + help_text=_("Percentage score for a " + "'B' >=")) + product_grade_c = models.IntegerField(default=70, + verbose_name=_("Grade C"), + help_text=_("Percentage score for a " + "'C' >=")) + product_grade_d = models.IntegerField(default=60, + verbose_name=_("Grade D"), + help_text=_("Percentage score for a " + "'D' >=")) + product_grade_f = models.IntegerField(default=59, + verbose_name=_("Grade F"), + help_text=_("Percentage score for an " + "'F' <=")) + enable_product_tag_inheritance = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Product Tag Inheritance"), + help_text=_("Enables product tag inheritance globally for all products. Any tags added on a product will automatically be added to all Engagements, Tests, and Findings")) + + enable_benchmark = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Benchmarks"), + help_text=_("Enables Benchmarks such as the OWASP ASVS " + "(Application Security Verification Standard)")) + + enable_similar_findings = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Similar Findings"), + help_text=_("Enable the query of similar findings on the view finding page. This feature can involve potentially large queries and negatively impact performance")) + + engagement_auto_close = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Engagement Auto-Close"), + help_text=_("Closes an engagement after 3 days (default) past due date including last update.")) + + engagement_auto_close_days = models.IntegerField( + default=3, + blank=False, + verbose_name=_("Engagement Auto-Close Days"), + help_text=_("Closes an engagement after the specified number of days past due date including last update.")) + + enable_finding_sla = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Finding SLA's"), + help_text=_("Enables Finding SLA's for time to remediate.")) + + enable_notify_sla_active = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Notify SLA's Breach for active Findings"), + help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active Findings.")) + + enable_notify_sla_active_verified = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Notify SLA's Breach for active, verified Findings"), + help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for active, verified Findings.")) + + enable_notify_sla_jira_only = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable Notify SLA's Breach only for Findings linked to JIRA"), + help_text=_("Enables Notify when time to remediate according to Finding SLA's is breached for Findings that are linked to JIRA issues. Notification is disabled for Findings not linked to JIRA issues")) + + enable_notify_sla_exponential_backoff = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Enable an exponential backoff strategy for SLA breach notifications."), + help_text=_("Enable an exponential backoff strategy for SLA breach notifications, e.g. 1, 2, 4, 8, etc. Otherwise it alerts every day")) + + allow_anonymous_survey_repsonse = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Allow Anonymous Survey Responses"), + help_text=_("Enable anyone with a link to the survey to answer a survey"), + ) + disclaimer_notifications = models.TextField(max_length=3000, default="", blank=True, + verbose_name=_("Custom Disclaimer for Notifications"), + help_text=_("Include this custom disclaimer on all notifications")) + disclaimer_reports = models.TextField(max_length=5000, default="", blank=True, + verbose_name=_("Custom Disclaimer for Reports"), + help_text=_("Include this custom disclaimer on generated reports")) + disclaimer_reports_forced = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Force to add disclaimer reports"), + help_text=_("Disclaimer will be added to all reports even if user didn't selected 'Include disclaimer'.")) + disclaimer_notes = models.TextField(max_length=3000, default="", blank=True, + verbose_name=_("Custom Disclaimer for Notes"), + help_text=_("Include this custom disclaimer next to input form for notes")) + risk_acceptance_form_default_days = models.IntegerField(null=True, blank=True, default=180, help_text=_("Default expiry period for risk acceptance form.")) + risk_acceptance_notify_before_expiration = models.IntegerField(null=True, blank=True, default=10, + verbose_name=_("Risk acceptance expiration heads up days"), help_text=_("Notify X days before risk acceptance expires. Leave empty to disable.")) + enable_questionnaires = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable questionnaires"), + help_text=_("With this setting turned off, questionnaires will be disabled in the user interface.")) + enable_checklists = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable checklists"), + help_text=_("With this setting turned off, checklists will be disabled in the user interface.")) + enable_endpoint_metadata_import = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Endpoint Metadata Import"), + help_text=_("With this setting turned off, endpoint metadata import will be disabled in the user interface.")) + enable_user_profile_editable = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable user profile for writing"), + help_text=_("When turned on users can edit their profiles")) + enable_product_tracking_files = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Product Tracking Files"), + help_text=_("With this setting turned off, the product tracking files will be disabled in the user interface.")) + enable_finding_groups = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Finding Groups"), + help_text=_("With this setting turned off, the Finding Groups will be disabled.")) + enable_ui_table_based_searching = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable UI Table Based Filtering/Sorting"), + help_text=_("With this setting enabled, table headings will contain sort buttons for the current page of data in addition to sorting buttons that consider data from all pages.")) + enable_calendar = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable Calendar"), + help_text=_("With this setting turned off, the Calendar will be disabled in the user interface.")) + enable_cvss3_display = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable CVSS3 Display"), + help_text=_("With this setting turned off, CVSS3 fields will be hidden in the user interface.")) + enable_cvss4_display = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Enable CVSS4 Display"), + help_text=_("With this setting turned off, CVSS4 fields will be hidden in the user interface.")) + minimum_password_length = models.IntegerField( + default=9, + verbose_name=_("Minimum password length"), + help_text=_("Requires user to set passwords greater than minimum length."), + validators=[MinValueValidator(9), MaxValueValidator(48)]) + maximum_password_length = models.IntegerField( + default=48, + verbose_name=_("Maximum password length"), + help_text=_("Requires user to set passwords less than maximum length."), + validators=[MinValueValidator(9), MaxValueValidator(48)]) + number_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one digit"), + help_text=_("Requires user passwords to contain at least one digit (0-9).")) + special_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one special character"), + help_text=_("Requires user passwords to contain at least one special character (()[]{}|\\`~!@#$%^&*_-+=;:'\",<>./?).")) + lowercase_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one lowercase letter"), + help_text=_("Requires user passwords to contain at least one lowercase letter (a-z).")) + uppercase_character_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must contain one uppercase letter"), + help_text=_("Requires user passwords to contain at least one uppercase letter (A-Z).")) + non_common_password_required = models.BooleanField( + default=True, + blank=False, + verbose_name=_("Password must not be common"), + help_text=_("Requires user passwords to not be part of list of common passwords.")) + api_expose_error_details = models.BooleanField( + default=False, + blank=False, + verbose_name=_("API expose error details"), + help_text=_("When turned on, the API will expose error details in the response.")) + filter_string_matching = models.BooleanField( + default=False, + blank=False, + verbose_name=_("Filter String Matching Optimization"), + help_text=_( + "When turned on, all filter operations in the UI will require string matches rather than ID. " + "This is a performance enhancement to avoid fetching objects unnecessarily.", + )) + + from dojo.middleware import System_Settings_Manager # noqa: PLC0415 circular import + objects = System_Settings_Manager() + + def clean(self): + super().clean() + + if ( + self.minimum_password_length is not None + and self.maximum_password_length is not None + ): + if self.minimum_password_length > self.maximum_password_length: + msg = "Minimum required password length must be larger than the maximum required password length." + raise ValidationError({ + "minimum_password_length": msg, + }) diff --git a/dojo/system_settings/ui/__init__.py b/dojo/system_settings/ui/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/system_settings/ui/forms.py b/dojo/system_settings/ui/forms.py new file mode 100644 index 00000000000..699ed2add7f --- /dev/null +++ b/dojo/system_settings/ui/forms.py @@ -0,0 +1,41 @@ +from django import forms + +from dojo.labels import get_labels +from dojo.system_settings.models import System_Settings + +labels = get_labels() + + +class SystemSettingsForm(forms.ModelForm): + jira_webhook_secret = forms.CharField(required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["enable_product_tracking_files"].label = labels.SETTINGS_TRACKED_FILES_ENABLE_LABEL + self.fields["enable_product_tracking_files"].help_text = labels.SETTINGS_TRACKED_FILES_ENABLE_HELP + + self.fields[ + "enforce_verified_status_product_grading"].label = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_LABEL + self.fields[ + "enforce_verified_status_product_grading"].help_text = labels.SETTINGS_ASSET_GRADING_ENFORCE_VERIFIED_HELP + + self.fields["enable_product_grade"].label = labels.SETTINGS_ASSET_GRADING_ENABLE_LABEL + self.fields["enable_product_grade"].help_text = labels.SETTINGS_ASSET_GRADING_ENABLE_HELP + + self.fields["enable_product_tag_inheritance"].label = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_LABEL + self.fields["enable_product_tag_inheritance"].help_text = labels.SETTINGS_ASSET_TAG_INHERITANCE_ENABLE_HELP + + def clean(self): + cleaned_data = super().clean() + enable_jira_value = cleaned_data.get("enable_jira") + jira_webhook_secret_value = cleaned_data.get("jira_webhook_secret").strip() + + if enable_jira_value and not jira_webhook_secret_value: + self.add_error("jira_webhook_secret", "This field is required when enable Jira Integration is True") + + return cleaned_data + + class Meta: + model = System_Settings + exclude = () diff --git a/dojo/system_settings/urls.py b/dojo/system_settings/ui/urls.py similarity index 87% rename from dojo/system_settings/urls.py rename to dojo/system_settings/ui/urls.py index 8268f6ee0ca..ff93931611d 100644 --- a/dojo/system_settings/urls.py +++ b/dojo/system_settings/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import re_path -from dojo.system_settings import views +from dojo.system_settings.ui import views urlpatterns = [ re_path( diff --git a/dojo/system_settings/views.py b/dojo/system_settings/ui/views.py similarity index 97% rename from dojo/system_settings/views.py rename to dojo/system_settings/ui/views.py index 2b375627ae2..a2088f4b7ea 100644 --- a/dojo/system_settings/views.py +++ b/dojo/system_settings/ui/views.py @@ -6,8 +6,8 @@ from django.shortcuts import render from django.views import View -from dojo.forms import SystemSettingsForm -from dojo.models import System_Settings +from dojo.system_settings.models import System_Settings +from dojo.system_settings.ui.forms import SystemSettingsForm from dojo.utils import add_breadcrumb logger = logging.getLogger(__name__) diff --git a/dojo/urls.py b/dojo/urls.py index 86697752349..7992d87f3f3 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -37,7 +37,6 @@ SLAConfigurationViewset, SonarqubeIssueTransitionViewSet, SonarqubeIssueViewSet, - SystemSettingsViewSet, ToolConfigurationsViewSet, ToolProductSettingsViewSet, ToolTypesViewSet, @@ -75,7 +74,8 @@ from dojo.search.urls import urlpatterns as search_urls from dojo.sla_config.urls import urlpatterns as sla_urls from dojo.survey.urls import urlpatterns as survey_urls -from dojo.system_settings.urls import urlpatterns as system_settings_urls +from dojo.system_settings.api.urls import add_system_settings_urls +from dojo.system_settings.ui.urls import urlpatterns as system_settings_urls from dojo.test.api.urls import add_test_urls from dojo.test.ui.urls import urlpatterns as test_urls from dojo.test_type.urls import urlpatterns as test_type_urls @@ -136,7 +136,7 @@ v2_api.register(r"sla_configurations", SLAConfigurationViewset, basename="sla_configurations") v2_api.register(r"sonarqube_issues", SonarqubeIssueViewSet, basename="sonarqube_issue") v2_api.register(r"sonarqube_transitions", SonarqubeIssueTransitionViewSet, basename="sonarqube_issue_transition") -v2_api.register(r"system_settings", SystemSettingsViewSet, basename="system_settings") +v2_api = add_system_settings_urls(v2_api) v2_api.register(r"technologies", AppAnalysisViewSet, basename="app_analysis") v2_api = add_test_urls(v2_api) v2_api.register(r"tool_configurations", ToolConfigurationsViewSet, basename="tool_configuration")