Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a global setting for the default language #888

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
path("get_metrics/", get_metrics_view, name="get_metrics_view"),
path("agg_data/", get_agg_data, name="get_agg_data"),
path("composer_data/", get_composer_data, name="get_composer_data"),
path("preferences/", UpdatePreferences.as_view(), name="preferences"),
path("i18n/", include("django.conf.urls.i18n")),
path(
"accounts/saml/", include("iam.sso.saml.urls")
Expand Down
22 changes: 18 additions & 4 deletions backend/core/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import csv
import mimetypes
import re
import os
import tempfile
import uuid
import zipfile
import importlib
from datetime import date, datetime, timedelta
import time
import pytz
Expand Down Expand Up @@ -71,8 +73,7 @@
MED_CACHE_TTL = 5 # mn
LONG_CACHE_TTL = 60 # mn

SETTINGS_MODULE = __import__(os.environ.get("DJANGO_SETTINGS_MODULE"))
MODULE_PATHS = SETTINGS_MODULE.settings.MODULE_PATHS
SETTINGS_MODULE = importlib.import_module(os.environ.get("DJANGO_SETTINGS_MODULE"))


class BaseModelViewSet(viewsets.ModelViewSet):
Expand Down Expand Up @@ -107,6 +108,7 @@ def get_queryset(self):
return queryset

def get_serializer_class(self, **kwargs):
MODULE_PATHS = SETTINGS_MODULE.MODULE_PATHS
serializer_factory = SerializerFactory(
self.serializers_module, MODULE_PATHS.get("serializers", [])
)
Expand All @@ -123,9 +125,12 @@ def get_serializer_class(self, **kwargs):

return serializer_class

# This constant must remain defined right before the _process_request_data if we ever move _process_request_data elsewhere
COMMA_SEPARATED_UUIDS_REGEX = r"^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}(,[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})*$"

def _process_request_data(self, request: Request) -> None:
# Maybe this should be consider as an helper function instead of a staticmethod attached to a class.
@staticmethod
def _process_request_data(request: Request) -> None:
"""
Process the request data to split comma-separated UUIDs into a list
and handle empty list scenarios.
Expand All @@ -138,7 +143,7 @@ def _process_request_data(self, request: Request) -> None:
# TODO: Come back to this once superForms v2 is out of alpha. https://github.com/ciscoheat/sveltekit-superforms/releases
if isinstance(request.data[field], list) and len(request.data[field]) == 1:
if isinstance(request.data[field][0], str) and re.match(
self.COMMA_SEPARATED_UUIDS_REGEX, request.data[field][0]
BaseModelViewSet.COMMA_SEPARATED_UUIDS_REGEX, request.data[field][0]
):
request.data[field] = request.data[field][0].split(",")
elif not request.data[field][0]:
Expand Down Expand Up @@ -1427,6 +1432,15 @@ def get_composer_data(request):
return Response({"result": data})


class UpdatePreferences(APIView):
def get(self, request):
return Response(request.user.preferences)

def patch(self, request):
request.user.update_preferences(request.data)
return Response(status=status.HTTP_200_OK)


# Compliance Assessment


Expand Down
3 changes: 2 additions & 1 deletion backend/global_settings/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class Names(models.TextChoices):
choices=Names,
default=Names.GENERAL,
)
# Value of the setting.
# Value of the setting
# This field must always be of type Dict[str, Any]
value = models.JSONField(default=dict)

def __str__(self):
Expand Down
12 changes: 10 additions & 2 deletions backend/global_settings/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@

from iam.sso.views import SSOSettingsViewSet

from .views import GlobalSettingsViewSet, get_sso_info
from .views import (
GlobalSettingsViewSet,
get_sso_info,
update_general_settings,
get_general_settings,
)
from .routers import DefaultSettingsRouter


Expand All @@ -17,8 +22,11 @@
basename="sso-settings",
)


urlpatterns = [
# This route should ideally be placed under the routes of the routers, but the DefaultRouter usage overwrite the route and makes it inaccessible.
# Could we use DefaultSettingsRouter to register the "global" route to fix that ?
path(r"general/update/", update_general_settings, name="update_general_settings"),
path(r"general/info/", get_general_settings, name="get_general_settings"),
path(r"", include(router.urls)),
path(r"", include(settings_router.urls)),
path(r"sso/info/", get_sso_info, name="get_sso_info"),
Expand Down
54 changes: 53 additions & 1 deletion backend/global_settings/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rest_framework.response import Response
from ciso_assistant.settings import CISO_ASSISTANT_URL

from core.views import BaseModelViewSet
from iam.sso.models import SSOSettings

from .serializers import GlobalSettingsSerializer
Expand Down Expand Up @@ -33,11 +34,62 @@ def update(self, request, *args, **kwargs):
)


UPDATABLE_GENERAL_SETTINGS = frozenset(
["lang"]
) # This represents the list of "general" GlobalSettings an admin has the right to change.
PUBLIC_GENERAL_SETTINGS = [
"lang"
] # List of general settings accessible by anyone (non-sensitive general settings).


@api_view(["GET"])
@permission_classes([permissions.AllowAny])
def get_general_settings(request):
"""
API endpoint to get the general settings.
"""
general_settings = GlobalSettings.objects.filter(name="general").first()
if general_settings is None:
public_settings = {}
else:
public_settings = {
key: general_settings.value.get(key) for key in PUBLIC_GENERAL_SETTINGS
}
return Response(public_settings)


@api_view(["PATCH"])
@permission_classes([permissions.IsAdminUser])
def update_general_settings(request):
"""
API endpoint to update general settings as an administrator.
"""
BaseModelViewSet._process_request_data(request)

general_settings = GlobalSettings.objects.filter(name="general").first()
if general_settings is not None:
general_settings = general_settings.value
else:
general_settings = {}

for key, value in request.data.items():
# There is no schema verification for this
# An attacker may be able to break a ciso-assistant instance by injecting values with bad types in future general settings.
if key in UPDATABLE_GENERAL_SETTINGS:
general_settings[key] = value

GlobalSettings.objects.update_or_create(
name="general", defaults={"value": general_settings}
)

return Response({})


@api_view(["GET"])
@permission_classes([permissions.AllowAny])
def get_sso_info(request):
"""
API endpoint that returns the CSRF token.
API endpoint that return the SSO configuration info
"""
settings = SSOSettings.objects.get()
sp_entity_id = settings.settings["sp"].get("entity_id")
Expand Down
17 changes: 17 additions & 0 deletions backend/iam/migrations/0009_user_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.1 on 2024-10-10 11:46

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("iam", "0008_user_is_third_party"),
]

operations = [
migrations.AddField(
model_name="user",
name="preferences",
field=models.JSONField(default=dict),
),
]
18 changes: 17 additions & 1 deletion backend/iam/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
Inspired from Azure IAM model"""

from collections import defaultdict
from typing import Any, List, Self, Tuple
from typing import Any, Dict, List, Self, Tuple
import uuid
from django.forms import JSONField
from django.utils import timezone
from django.db import models
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
Expand Down Expand Up @@ -358,6 +359,7 @@ class User(AbstractBaseUser, AbstractBaseModel, FolderMixin):
"granted to each of their user groups."
),
)
preferences = models.JSONField(default=dict)
objects = CaseInsensitiveUserManager()

# USERNAME_FIELD is used as the unique identifier for the user
Expand All @@ -366,6 +368,9 @@ class User(AbstractBaseUser, AbstractBaseModel, FolderMixin):
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []

# This is the set of of keys allowed in the preferences JSONField
PREFERENCE_SET = {"lang"}

class Meta:
"""for Model"""

Expand Down Expand Up @@ -474,6 +479,12 @@ def get_user_groups(self):
"""get the list of user groups containing the user in the form (group_name, builtin)"""
return [(x.__str__(), x.builtin) for x in self.user_groups.all()]

def update_preferences(self, new_preferences: Dict[str, Any]):
for key, value in new_preferences.items():
if key in self.PREFERENCE_SET:
self.preferences[key] = value
self.save()

def get_roles(self):
"""get the list of roles attached to the user"""
return list(
Expand Down Expand Up @@ -514,6 +525,11 @@ def get_admin_users() -> List[Self]:
def is_admin(self) -> bool:
return self.user_groups.filter(name="BI-UG-ADM").exists()

# The following property exist solely for compatibilty between the User model and the DRF permission class IsAdminUser
@property
def is_staff(self) -> bool:
return self.is_admin()

@property
def is_editor(self) -> bool:
permissions = RoleAssignment.get_permissions(self)
Expand Down
7 changes: 3 additions & 4 deletions backend/library/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
HTTP_400_BAD_REQUEST,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_422_UNPROCESSABLE_ENTITY,
)
from rest_framework.parsers import FileUploadParser

Expand Down Expand Up @@ -129,7 +128,7 @@ def import_library(self, request, pk):
except Exception:
return Response(
{"error": "Failed to load library"}, # This must translated
status=HTTP_422_UNPROCESSABLE_ENTITY,
status=HTTP_400_BAD_REQUEST,
)

@action(detail=True, methods=["get"])
Expand Down Expand Up @@ -172,7 +171,7 @@ def upload_library(self, request):
logger.error("Failed to store library content", error=e)
return HttpResponse(
json.dumps({"error": "Failed to store library content."}),
status=HTTP_422_UNPROCESSABLE_ENTITY,
status=HTTP_400_BAD_REQUEST,
)

return HttpResponse(json.dumps({}), status=HTTP_200_OK)
Expand Down Expand Up @@ -323,5 +322,5 @@ def _update(self, request, pk):
if error_msg is None:
return Response(status=HTTP_204_NO_CONTENT)
return Response(
error_msg, status=HTTP_422_UNPROCESSABLE_ENTITY
error_msg, status=HTTP_400_BAD_REQUEST
) # We must make at least one error message
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,30 @@
}
}}><i class="fa-solid fa-key" /> {m.clientSettings()}</Tab
>
<Tab bind:group={tabSet} name="ssoSettings" value={2}
><i class="fa-solid fa-cog" /> {m.generalSettings()}</Tab
>
</TabGroup>
{#if tabSet === 0}
<div>
<span class="text-gray-500">{m.ssoSettingsDescription()}</span>
<ModelForm
form={data.form}
form={data.ssoForm}
schema={SSOSettingsSchema}
model={data.model}
model={data.ssoModel}
cancelButton={false}
/>
</div>
{:else if tabSet === 1}
<ClientSettings data={$page.state.clientSettings} />
{:else if tabSet === 2}
<div>
<span class="text-gray-500">{m.ssoSettingsDescription()}</span>
<ModelForm
form={data.generalSettingForm}
model={data.generalSettingModel}
cancelButton={false}
action="?/general"
/>
</div>
{/if}
5 changes: 4 additions & 1 deletion frontend/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"frameworkColon": "نطاق:",
"file": "الملف",
"language": "اللغة",
"defaultLanguage": "اللغة الافتراضية",
"builtin": "مضمن",
"next": "التالي",
"previous": "السابق",
Expand Down Expand Up @@ -643,8 +644,10 @@
"or": "أو",
"errorImportingLibrary": "خطأ أثناء استيراد المكتبة",
"libraryImportError": "حدث خطأ أثناء استيراد المكتبة",
"ssoSettingsupdated": "تم تحديث إعدادات تسجيل الدخول الموحد",
"ssoSettingsUpdated": "تم تحديث إعدادات تسجيل الدخول الموحد",
"generalSettingsUpdated": "تم تحديث الإعدادات العامة",
"ssoSettings": "إعدادات تسجيل الدخول الموحد",
"generalSettings": "الإعدادات العامة",
"ssoSettingsDescription": "قم بتكوين إعدادات تسجيل الدخول الموحد هنا.",
"sso": "SSO",
"isSso": "هل هو SSO",
Expand Down
5 changes: 4 additions & 1 deletion frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"frameworkColon": "Rahmen:",
"file": "Datei",
"language": "Sprache",
"defaultLanguage": "Standardsprache",
"builtin": "Eingebaut",
"next": "Weiter",
"previous": "Zurück",
Expand Down Expand Up @@ -643,8 +644,10 @@
"or": "oder",
"errorImportingLibrary": "Fehler beim Importieren der Bibliothek",
"libraryImportError": "Beim Importieren Ihrer Bibliothek ist ein Fehler aufgetreten.",
"ssoSettingsupdated": "SSO-Einstellungen aktualisiert",
"ssoSettingsUpdated": "SSO-Einstellungen aktualisiert",
"generalSettingsUpdated": "Allgemeine Einstellungen aktualisiert",
"ssoSettings": "SSO-Einstellungen",
"generalSettings": "Allgemeine Einstellungen",
"ssoSettingsDescription": "Konfigurieren Sie hier Ihre Single Sign-On-Einstellungen.",
"sso": "SSO",
"isSso": "Ist SSO",
Expand Down
5 changes: 4 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"frameworkColon": "Framework:",
"file": "File",
"language": "Language",
"defaultLanguage": "Default language",
"builtin": "Builtin",
"next": "Next",
"previous": "Previous",
Expand Down Expand Up @@ -643,8 +644,10 @@
"or": "or",
"errorImportingLibrary": "Error during library import",
"libraryImportError": "An error occurred during library import",
"ssoSettingsupdated": "SSO settings updated",
"ssoSettingsUpdated": "SSO settings updated",
"generalSettingsUpdated": "General settings updated",
"ssoSettings": "SSO settings",
"generalSettings": "General settings",
"ssoSettingsDescription": "Configure your Single Sign-On settings here.",
"sso": "SSO",
"isSso": "Is SSO",
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"frameworkColon": "Estructura:",
"file": "Archivo",
"language": "Idioma",
"defaultLanguage": "Idioma predeterminado",
"builtin": "Integrado",
"next": "Siguiente",
"previous": "Anterior",
Expand Down
Loading
Loading