From 59d229760837bf20478885891765904eb1f5602f Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 18:17:22 +0200 Subject: [PATCH 01/11] Write is_editor and get_editors methods --- backend/iam/models.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/iam/models.py b/backend/iam/models.py index 08b21d686..4eb8c275e 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -502,6 +502,19 @@ def get_admin_users() -> List[Self]: def is_admin(self) -> bool: return self.user_groups.filter(name="BI-UG-ADM").exists() + @property + def is_editor(self) -> bool: + permissions = RoleAssignment.get_permissions(self) + editor_prefixes = {"add_", "change_", "delete_"} + return any( + any(perm.startswith(prefix) for prefix in editor_prefixes) + for perm in permissions + ) + + @classmethod + def get_editors(cls) -> List[Self]: + return [user for user in cls.objects.all() if user.is_editor] + class Role(NameDescriptionMixin, FolderMixin): """A role is a list of permissions""" From b14ded478f63a2392cc8302fb5cee874bdb3c171 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 18:19:13 +0200 Subject: [PATCH 02/11] Write unit tests for is_editor and get_editors methods --- backend/iam/tests/test_user.py | 116 +++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 backend/iam/tests/test_user.py diff --git a/backend/iam/tests/test_user.py b/backend/iam/tests/test_user.py new file mode 100644 index 000000000..b07594bef --- /dev/null +++ b/backend/iam/tests/test_user.py @@ -0,0 +1,116 @@ +import pytest +from django.contrib.auth.models import Permission + +from core.tests.fixtures import * +from iam.models import Folder, Role, RoleAssignment, User + + +@pytest.mark.django_db +class TestUser: + pytestmark = pytest.mark.django_db + + @pytest.mark.usefixtures("domain_project_fixture") + def test_reader_user_is_not_editor(self): + user = User.objects.create_user(email="root@example.com", password="password") + assert user is not None + + folder = Folder.objects.filter(content_type=Folder.ContentType.DOMAIN).last() + reader_role = Role.objects.create(name="test reader") + reader_permissions = Permission.objects.filter( + codename__in=[ + "view_project", + "view_riskassessment", + "view_appliedcontrol", + "view_riskscenario", + "view_riskacceptance", + "view_asset", + "view_threat", + "view_referencecontrol", + "view_folder", + "view_usergroup", + ] + ) + reader_role.permissions.set(reader_permissions) + reader_role.save() + reader_role_assignment = RoleAssignment.objects.create( + user=user, + role=reader_role, + folder=folder, + is_recursive=True, + ) + reader_role_assignment.perimeter_folders.add(folder) + reader_role_assignment.save() + + assert not user.is_editor + + editors = User.get_editors() + assert len(editors) == 0 + assert user not in editors + + @pytest.mark.usefixtures("domain_project_fixture") + def test_editor_user_is_editor(self): + user = User.objects.create_user(email="root@example.com", password="password") + assert user is not None + + folder = Folder.objects.filter(content_type=Folder.ContentType.DOMAIN).last() + editor_role = Role.objects.create(name="test editor") + editor_permissions = Permission.objects.filter( + codename__in=[ + "view_project", + "view_riskassessment", + "view_appliedcontrol", + "view_riskscenario", + "view_riskacceptance", + "view_asset", + "view_threat", + "view_referencecontrol", + "view_folder", + "view_usergroup", + "add_project", + "change_project", + "delete_project", + "add_riskassessment", + "change_riskassessment", + "delete_riskassessment", + "add_appliedcontrol", + "change_appliedcontrol", + "delete_appliedcontrol", + "add_riskscenario", + "change_riskscenario", + "delete_riskscenario", + "add_riskacceptance", + "change_riskacceptance", + "delete_riskacceptance", + "add_asset", + "change_asset", + "delete_asset", + "add_threat", + "change_threat", + "delete_threat", + "add_referencecontrol", + "change_referencecontrol", + "delete_referencecontrol", + "add_folder", + "change_folder", + "delete_folder", + "add_usergroup", + "change_usergroup", + "delete_usergroup", + ] + ) + editor_role.permissions.set(editor_permissions) + editor_role.save() + editor_role_assignment = RoleAssignment.objects.create( + user=user, + role=editor_role, + folder=folder, + is_recursive=True, + ) + editor_role_assignment.perimeter_folders.add(folder) + editor_role_assignment.save() + + assert user.is_editor + + editors = User.get_editors() + assert len(editors) == 1 + assert user in editors From 403cb5707cbe8934585c31c825af5496a9b31c0a Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 18:33:01 +0200 Subject: [PATCH 03/11] SafeTranslate error messages in flash messages --- .../settings/client-settings/+page.server.ts | 7 +++---- .../libraries/[id=uuid]/+page.server.ts | 8 ++++---- .../users/[id=uuid]/edit/+page.server.ts | 16 ++++++++-------- .../first-connexion/+page.server.ts | 13 +++++++------ .../password-reset/+page.server.ts | 17 +++++++++-------- .../password-reset/confirm/+page.server.ts | 13 +++++++------ 6 files changed, 38 insertions(+), 36 deletions(-) diff --git a/enterprise/frontend/src/routes/(app)/(internal)/settings/client-settings/+page.server.ts b/enterprise/frontend/src/routes/(app)/(internal)/settings/client-settings/+page.server.ts index c642a3b8e..0da11d9ad 100644 --- a/enterprise/frontend/src/routes/(app)/(internal)/settings/client-settings/+page.server.ts +++ b/enterprise/frontend/src/routes/(app)/(internal)/settings/client-settings/+page.server.ts @@ -1,12 +1,12 @@ +import { ClientSettingsSchema } from '$lib/utils/client-settings'; import { BASE_API_URL } from '$lib/utils/constants'; import { safeTranslate } from '$lib/utils/i18n'; -import { ClientSettingsSchema } from '$lib/utils/client-settings'; +import * as m from '$paraglide/messages'; import { fail, type Actions } from '@sveltejs/kit'; import { setFlash } from 'sveltekit-flash-message/server'; import { setError, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; import type { PageServerLoad } from './$types'; -import * as m from '$paraglide/messages'; export const load: PageServerLoad = async ({ fetch }) => { const settings = await fetch(`${BASE_API_URL}/client-settings/`) @@ -62,7 +62,7 @@ export const actions: Actions = { return { form }; } if (response.error) { - setFlash({ type: 'error', message: response.error }, event); + setFlash({ type: 'error', message: safeTranslate(response.error) }, event); return { form }; } Object.entries(response).forEach(([key, value]) => { @@ -98,7 +98,6 @@ export const actions: Actions = { } } - const modelVerboseName: string = 'clientSettings'; return setFlash( { diff --git a/frontend/src/routes/(app)/(internal)/libraries/[id=uuid]/+page.server.ts b/frontend/src/routes/(app)/(internal)/libraries/[id=uuid]/+page.server.ts index ae1975678..bad6a1d6c 100644 --- a/frontend/src/routes/(app)/(internal)/libraries/[id=uuid]/+page.server.ts +++ b/frontend/src/routes/(app)/(internal)/libraries/[id=uuid]/+page.server.ts @@ -1,9 +1,9 @@ import { BASE_API_URL } from '$lib/utils/constants'; +import { safeTranslate } from '$lib/utils/i18n'; +import { localItems } from '$lib/utils/locales'; +import * as m from '$paraglide/messages'; import { fail, type Actions } from '@sveltejs/kit'; import { setFlash } from 'sveltekit-flash-message/server'; -import * as m from '$paraglide/messages'; -import { localItems } from '$lib/utils/locales'; -import { languageTag } from '$paraglide/runtime'; export const actions: Actions = { load: async (event) => { @@ -12,7 +12,7 @@ export const actions: Actions = { if (!res.ok) { const response = await res.json(); console.error('server response:', response); - setFlash({ type: 'error', message: response.error }, event); + setFlash({ type: 'error', message: safeTranslate(response.error) }, event); return fail(400, { error: m.errorLoadingLibrary() }); } setFlash( diff --git a/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts b/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts index f26cda267..6b47a6110 100644 --- a/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts @@ -1,14 +1,14 @@ import { BASE_API_URL } from '$lib/utils/constants'; -import { UserEditSchema } from '$lib/utils/schemas'; -import { setError, superValidate } from 'sveltekit-superforms'; -import type { PageServerLoad } from './$types'; -import { getSecureRedirect } from '$lib/utils/helpers'; -import { redirect, fail, type Actions } from '@sveltejs/kit'; import { getModelInfo } from '$lib/utils/crud'; -import { setFlash } from 'sveltekit-flash-message/server'; +import { getSecureRedirect } from '$lib/utils/helpers'; +import { safeTranslate } from '$lib/utils/i18n'; +import { UserEditSchema } from '$lib/utils/schemas'; import * as m from '$paraglide/messages'; -import { localItems } from '$lib/utils/locales'; +import { fail, redirect, type Actions } from '@sveltejs/kit'; +import { setFlash } from 'sveltekit-flash-message/server'; +import { setError, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; +import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ params, fetch }) => { const URLModel = 'users'; @@ -61,7 +61,7 @@ export const actions: Actions = { const response = await res.json(); console.error('server response:', response); if (response.error) { - setFlash({ type: 'error', message: localItems()[response.error] }, event); + setFlash({ type: 'error', message: safeTranslate(response.error) }, event); return fail(403, { form: form }); } if (response.non_field_errors) { diff --git a/frontend/src/routes/(authentication)/first-connexion/+page.server.ts b/frontend/src/routes/(authentication)/first-connexion/+page.server.ts index 10f6fc333..8d8531661 100644 --- a/frontend/src/routes/(authentication)/first-connexion/+page.server.ts +++ b/frontend/src/routes/(authentication)/first-connexion/+page.server.ts @@ -1,11 +1,12 @@ -import { fail, redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { ResetPasswordSchema } from '$lib/utils/schemas'; -import { setError, superValidate } from 'sveltekit-superforms'; -import { setFlash } from 'sveltekit-flash-message/server'; import { BASE_API_URL } from '$lib/utils/constants'; +import { safeTranslate } from '$lib/utils/i18n'; +import { ResetPasswordSchema } from '$lib/utils/schemas'; import * as m from '$paraglide/messages'; +import { fail, redirect, type Actions } from '@sveltejs/kit'; +import { setFlash } from 'sveltekit-flash-message/server'; +import { setError, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; +import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { const form = await superValidate(event.request, zod(ResetPasswordSchema)); @@ -40,7 +41,7 @@ export const actions: Actions = { setError(form, 'confirm_new_password', response.confirm_new_password); } if (response.error) { - setFlash({ type: 'error', message: response.error }, event); + setFlash({ type: 'error', message: safeTranslate(response.error) }, event); redirect(302, '/login'); } return fail(400, { form }); diff --git a/frontend/src/routes/(authentication)/password-reset/+page.server.ts b/frontend/src/routes/(authentication)/password-reset/+page.server.ts index 8d8b7ea66..2a61fc987 100644 --- a/frontend/src/routes/(authentication)/password-reset/+page.server.ts +++ b/frontend/src/routes/(authentication)/password-reset/+page.server.ts @@ -1,13 +1,14 @@ -import { fail, redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { emailSchema } from '$lib/utils/schemas'; -import { superValidate } from 'sveltekit-superforms'; -import { zod } from 'sveltekit-superforms/adapters'; -import { setFlash } from 'sveltekit-flash-message/server'; -import { RetryAfterRateLimiter } from 'sveltekit-rate-limiter/server'; import { BASE_API_URL } from '$lib/utils/constants'; import { csrfToken } from '$lib/utils/csrf'; +import { safeTranslate } from '$lib/utils/i18n'; +import { emailSchema } from '$lib/utils/schemas'; import * as m from '$paraglide/messages'; +import { fail, redirect, type Actions } from '@sveltejs/kit'; +import { setFlash } from 'sveltekit-flash-message/server'; +import { RetryAfterRateLimiter } from 'sveltekit-rate-limiter/server'; +import { superValidate } from 'sveltekit-superforms'; +import { zod } from 'sveltekit-superforms/adapters'; +import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { // redirect user if already logged in @@ -68,7 +69,7 @@ export const actions: Actions = { const response = await res.json(); console.log(response); if (response.error) { - setFlash({ type: 'error', message: response.error }, event); + setFlash({ type: 'error', message: safeTranslate(response.error) }, event); } redirect(302, '/login'); } diff --git a/frontend/src/routes/(authentication)/password-reset/confirm/+page.server.ts b/frontend/src/routes/(authentication)/password-reset/confirm/+page.server.ts index f985c12a9..126a95820 100644 --- a/frontend/src/routes/(authentication)/password-reset/confirm/+page.server.ts +++ b/frontend/src/routes/(authentication)/password-reset/confirm/+page.server.ts @@ -1,11 +1,12 @@ -import { fail, redirect, type Actions } from '@sveltejs/kit'; -import type { PageServerLoad } from './$types'; -import { ResetPasswordSchema } from '$lib/utils/schemas'; -import { setError, superValidate } from 'sveltekit-superforms'; -import { setFlash } from 'sveltekit-flash-message/server'; import { BASE_API_URL } from '$lib/utils/constants'; +import { safeTranslate } from '$lib/utils/i18n'; +import { ResetPasswordSchema } from '$lib/utils/schemas'; import * as m from '$paraglide/messages'; +import { fail, redirect, type Actions } from '@sveltejs/kit'; +import { setFlash } from 'sveltekit-flash-message/server'; +import { setError, superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; +import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async (event) => { const form = await superValidate(event.request, zod(ResetPasswordSchema)); @@ -40,7 +41,7 @@ export const actions: Actions = { setError(form, 'confirm_new_password', response.confirm_new_password); } if (response.error) { - setFlash({ type: 'error', message: response.error }, event); + setFlash({ type: 'error', message: safeTranslate(response.error) }, event); redirect(302, '/login'); } return fail(400, { form }); From b68f1b8758f7567e51a52241ee6d1d8a2a8a5c1b Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 18:34:07 +0200 Subject: [PATCH 04/11] Regionalize license seats exceeded error message --- frontend/messages/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8fc225019..db7c28861 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -750,5 +750,6 @@ "theFollowingControlsWillBeAddedColon": "The following controls will be added:", "ShowAllNodesMessage": "Show all", "ShowOnlyAssessable": "Only assessable", - "NoPreviewMessage": "No preview available." + "NoPreviewMessage": "No preview available.", + "errorLicenseSeatsExceeded": "The number of license seats is exceeded, you will not be able to grant editing rights to this user. Please contact your administrator." } From 3a73470b6db3096e25747bd8d5f87a4cfbfc7d0f Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 19:38:35 +0200 Subject: [PATCH 05/11] Force settings evaluation --- backend/core/views.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/backend/core/views.py b/backend/core/views.py index e11da72ff..199a035ad 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -39,10 +39,6 @@ from rest_framework.views import APIView from weasyprint import HTML -from ciso_assistant.settings import ( - BUILD, - VERSION, -) from core.helpers import * from core.models import ( AppliedControl, @@ -56,12 +52,19 @@ from .models import * from .serializers import * +import structlog + +logger = structlog.get_logger(__name__) + User = get_user_model() SHORT_CACHE_TTL = 2 # mn 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 + class BaseModelViewSet(viewsets.ModelViewSet): filter_backends = [ @@ -95,13 +98,19 @@ def get_queryset(self): return queryset def get_serializer_class(self, **kwargs): - MODULE_PATHS = settings.MODULE_PATHS serializer_factory = SerializerFactory( - self.serializers_module, *MODULE_PATHS.get("serializers", []) + self.serializers_module, MODULE_PATHS.get("serializers", []) ) serializer_class = serializer_factory.get_serializer( self.model.__name__, kwargs.get("action", self.action) ) + logger.debug( + "Serializer class", + serializer_class=serializer_class, + action=kwargs.get("action", self.action), + viewset=self, + module_paths=MODULE_PATHS, + ) return serializer_class @@ -1941,6 +1950,8 @@ def get_build(request): """ API endpoint that returns the build version of the application. """ + BUILD = settings.BUILD + VERSION = settings.VERSION return Response({"version": VERSION, "build": BUILD}) From 832c1a93e2826a68d1faefd9416a16d88477ec4f Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 19:38:51 +0200 Subject: [PATCH 06/11] Write UserGroup.permissions property method --- backend/iam/models.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/iam/models.py b/backend/iam/models.py index 4eb8c275e..88d27adad 100644 --- a/backend/iam/models.py +++ b/backend/iam/models.py @@ -235,6 +235,10 @@ def get_localization_dict(self) -> dict: "role": BUILTIN_USERGROUP_CODENAMES.get(self.name), } + @property + def permissions(self): + return RoleAssignment.get_permissions(self) + class UserManager(BaseUserManager): use_in_migrations = True @@ -731,18 +735,20 @@ def is_user_assigned(self, user) -> bool: ) @staticmethod - def get_role_assignments(user): + def get_role_assignments(principal: AbstractBaseUser | AnonymousUser | UserGroup): """get all role assignments attached to a user directly or indirectly""" - assignments = list(user.roleassignment_set.all()) - for user_group in user.user_groups.all(): - assignments += list(user_group.roleassignment_set.all()) + assignments = list(principal.roleassignment_set.all()) + if hasattr(principal, "user_groups"): + for user_group in principal.user_groups.all(): + assignments += list(user_group.roleassignment_set.all()) + assignments += list(principal.roleassignment_set.all()) return assignments @staticmethod - def get_permissions(user: AbstractBaseUser | AnonymousUser): + def get_permissions(principal: AbstractBaseUser | AnonymousUser | UserGroup): """get all permissions attached to a user directly or indirectly""" permissions = {} - for ra in RoleAssignment.get_role_assignments(user): + for ra in RoleAssignment.get_role_assignments(principal): for p in ra.role.permissions.all(): permission_dict = {p.codename: {"str": str(p)}} permissions.update(permission_dict) From 0b36cbadcfe57a4cb6d68f9cad141083868aca87 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 19:39:35 +0200 Subject: [PATCH 07/11] Prevent adding user groups with editing permissions if there is no seat available --- .../backend/enterprise_core/serializers.py | 75 ++++++++++++++++++- .../backend/enterprise_core/settings.py | 4 +- .../users/[id=uuid]/edit/+page.server.ts | 3 + 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/enterprise/backend/enterprise_core/serializers.py b/enterprise/backend/enterprise_core/serializers.py index 400c47735..2e566c1be 100644 --- a/enterprise/backend/enterprise_core/serializers.py +++ b/enterprise/backend/enterprise_core/serializers.py @@ -1,8 +1,15 @@ +from django.conf import settings from rest_framework import serializers -from core.serializers import BaseModelSerializer -from iam.models import Folder +from core.serializers import ( + BaseModelSerializer, + UserWriteSerializer as CommunityUserWriteSerializer, +) +from iam.models import Folder, User from .models import ClientSettings +import structlog + +logger = structlog.get_logger(__name__) class FolderWriteSerializer(BaseModelSerializer): @@ -14,6 +21,70 @@ class Meta: ] +class UserWriteSerializer(CommunityUserWriteSerializer): + def update(self, instance: User, validated_data): + editor_prefixes = {"add_", "change_", "delete_"} + editors = User.get_editors() + seats = settings.LICENSE_SEATS + if validated_data.get("user_groups"): + logger.info( + "Updating user groups", + user=instance, + groups=validated_data["user_groups"], + ) + for group in validated_data["user_groups"]: + perms = group.permissions + if any( + perm.startswith(prefix) + for prefix in editor_prefixes + for perm in perms + ): + logger.info( + "Adding editor permissions to user", user=instance, group=group + ) + if instance not in editors and len(editors) >= seats: + logger.error( + "License seats exceeded, cannot add editor user groups to user", + user=instance, + seats=seats, + ) + raise serializers.ValidationError( + {"user_groups": "errorLicenseSeatsExceeded"} + ) + return super().update(instance, validated_data) + + def partial_update(self, instance, validated_data): + editor_prefixes = {"add_", "change_", "delete_"} + editors = User.get_editors() + seats = settings.LICENSE_SEATS + if validated_data.get("user_groups"): + logger.info( + "Updating user groups", + user=instance, + groups=validated_data["user_groups"], + ) + for group in validated_data["user_groups"]: + perms = group.permissions + if any( + perm.startswith(prefix) + for prefix in editor_prefixes + for perm in perms + ): + logger.info( + "Adding editor permissions to user", user=instance, group=group + ) + if instance not in editors and len(editors) >= seats: + logger.error( + "License seats exceeded, cannot add editor user groups to user", + user=instance, + seats=seats, + ) + raise serializers.ValidationError( + {"user_groups": "errorLicenseSeatsExceeded"} + ) + return super().partial_update(instance, validated_data) + + class ClientSettingsWriteSerializer(BaseModelSerializer): class Meta: model = ClientSettings diff --git a/enterprise/backend/enterprise_core/settings.py b/enterprise/backend/enterprise_core/settings.py index dd6142ee6..60b96034f 100644 --- a/enterprise/backend/enterprise_core/settings.py +++ b/enterprise/backend/enterprise_core/settings.py @@ -82,7 +82,7 @@ def set_ciso_assistant_url(_, __, event_dict): logger = structlog.getLogger(__name__) FEATURE_FLAGS = {} -MODULE_PATHS = {} +MODULE_PATHS = {"serializers": "enterprise_core.serializers"} ROUTES = {} MODULES = {} @@ -385,8 +385,6 @@ def set_ciso_assistant_url(_, __, event_dict): }, } -MODULE_PATHS["serializers"] = ["enterprise_core.serializers"] - ROUTES["client-settings"] = { "viewset": "enterprise_core.views.ClientSettingsViewSet", "basename": "client-settings", diff --git a/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts b/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts index 6b47a6110..9ebbc8b5b 100644 --- a/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts +++ b/frontend/src/routes/(app)/(internal)/users/[id=uuid]/edit/+page.server.ts @@ -67,6 +67,9 @@ export const actions: Actions = { if (response.non_field_errors) { setError(form, 'non_field_errors', response.non_field_errors); } + Object.entries(response).forEach(([key, value]) => { + setError(form, key, safeTranslate(value)); + }); return fail(400, { form: form }); } setFlash( From 806a17eca669c4d464e44924089f22bef473de5d Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Thu, 3 Oct 2024 19:42:56 +0200 Subject: [PATCH 08/11] DRY enterprise user write serializer --- .../backend/enterprise_core/serializers.py | 73 +++++++------------ 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/enterprise/backend/enterprise_core/serializers.py b/enterprise/backend/enterprise_core/serializers.py index 2e566c1be..3c740cc13 100644 --- a/enterprise/backend/enterprise_core/serializers.py +++ b/enterprise/backend/enterprise_core/serializers.py @@ -21,11 +21,29 @@ class Meta: ] -class UserWriteSerializer(CommunityUserWriteSerializer): - def update(self, instance: User, validated_data): +class EditorPermissionMixin: + @staticmethod + def check_editor_permissions(instance, group): editor_prefixes = {"add_", "change_", "delete_"} editors = User.get_editors() seats = settings.LICENSE_SEATS + + perms = group.permissions + if any(perm.startswith(prefix) for prefix in editor_prefixes for perm in perms): + logger.info("Adding editor permissions to user", user=instance, group=group) + if instance not in editors and len(editors) >= seats: + logger.error( + "License seats exceeded, cannot add editor user groups to user", + user=instance, + seats=seats, + ) + raise serializers.ValidationError( + {"user_groups": "errorLicenseSeatsExceeded"} + ) + + +class UserWriteSerializer(CommunityUserWriteSerializer, EditorPermissionMixin): + def _update_user_groups(self, instance, validated_data): if validated_data.get("user_groups"): logger.info( "Updating user groups", @@ -33,55 +51,14 @@ def update(self, instance: User, validated_data): groups=validated_data["user_groups"], ) for group in validated_data["user_groups"]: - perms = group.permissions - if any( - perm.startswith(prefix) - for prefix in editor_prefixes - for perm in perms - ): - logger.info( - "Adding editor permissions to user", user=instance, group=group - ) - if instance not in editors and len(editors) >= seats: - logger.error( - "License seats exceeded, cannot add editor user groups to user", - user=instance, - seats=seats, - ) - raise serializers.ValidationError( - {"user_groups": "errorLicenseSeatsExceeded"} - ) + self.check_editor_permissions(instance, group) + + def update(self, instance: User, validated_data): + self._update_user_groups(instance, validated_data) return super().update(instance, validated_data) def partial_update(self, instance, validated_data): - editor_prefixes = {"add_", "change_", "delete_"} - editors = User.get_editors() - seats = settings.LICENSE_SEATS - if validated_data.get("user_groups"): - logger.info( - "Updating user groups", - user=instance, - groups=validated_data["user_groups"], - ) - for group in validated_data["user_groups"]: - perms = group.permissions - if any( - perm.startswith(prefix) - for prefix in editor_prefixes - for perm in perms - ): - logger.info( - "Adding editor permissions to user", user=instance, group=group - ) - if instance not in editors and len(editors) >= seats: - logger.error( - "License seats exceeded, cannot add editor user groups to user", - user=instance, - seats=seats, - ) - raise serializers.ValidationError( - {"user_groups": "errorLicenseSeatsExceeded"} - ) + self._update_user_groups(instance, validated_data) return super().partial_update(instance, validated_data) From 1ba9bb33cd707bcadfdbf185fbbd83dedb934abd Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Fri, 4 Oct 2024 10:56:37 +0200 Subject: [PATCH 09/11] Change default LICENSE_SEATS value to 1 --- enterprise/backend/enterprise_core/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/backend/enterprise_core/settings.py b/enterprise/backend/enterprise_core/settings.py index 60b96034f..8472928d8 100644 --- a/enterprise/backend/enterprise_core/settings.py +++ b/enterprise/backend/enterprise_core/settings.py @@ -399,7 +399,7 @@ def set_ciso_assistant_url(_, __, event_dict): "Enterprise startup info", feature_flags=FEATURE_FLAGS, module_paths=MODULE_PATHS ) -LICENSE_SEATS = int(os.environ.get("LICENSE_SEATS", 0)) +LICENSE_SEATS = int(os.environ.get("LICENSE_SEATS", 1)) LICENSE_EXPIRATION = os.environ.get("LICENSE_EXPIRATION", "unset") INSTALLED_APPS.append("enterprise_core") From c078273bc40dc28cf2c8897fb06440ad390cd1c6 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Fri, 4 Oct 2024 10:57:04 +0200 Subject: [PATCH 10/11] Specify LICENSE_SEATS in enterprise functional tests --- .github/workflows/functional-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/functional-tests.yml b/.github/workflows/functional-tests.yml index 1d4bd99bd..03bf7bccd 100644 --- a/.github/workflows/functional-tests.yml +++ b/.github/workflows/functional-tests.yml @@ -184,6 +184,7 @@ jobs: echo EMAIL_HOST_PASSWORD=password >> .env echo EMAIL_PORT=1025 >> .env echo DJANGO_SETTINGS_MODULE=enterprise_core.settings >> .env + echo LICENSE_SEATS=999 >> .env - name: Run migrations working-directory: ${{ env.backend-directory }} run: | From c0b3b8b60eccd806010370c6845472cc045af4b3 Mon Sep 17 00:00:00 2001 From: Nassim Tabchiche Date: Fri, 4 Oct 2024 19:26:41 +0200 Subject: [PATCH 11/11] Add available seats in About CISO Assistant modal --- enterprise/backend/enterprise_core/views.py | 7 ++++--- frontend/messages/en.json | 3 ++- frontend/src/lib/components/Modals/DisplayJSONModal.svelte | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/enterprise/backend/enterprise_core/views.py b/enterprise/backend/enterprise_core/views.py index 68ce2e16b..97e5043e7 100644 --- a/enterprise/backend/enterprise_core/views.py +++ b/enterprise/backend/enterprise_core/views.py @@ -1,9 +1,6 @@ -import mimetypes import magic import structlog -from core.views import BaseModelViewSet -from django.http import HttpResponse from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.decorators import ( @@ -16,6 +13,9 @@ from django.conf import settings +from core.views import BaseModelViewSet +from iam.models import User + from .models import ClientSettings from .serializers import ClientSettingsReadSerializer @@ -145,6 +145,7 @@ def get_build(request): "version": VERSION, "build": BUILD, "license_seats": LICENSE_SEATS, + "available_seats": LICENSE_SEATS - len(User.get_editors()), "license_expiration": LICENSE_EXPIRATION, } ) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index db7c28861..164c357f0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -751,5 +751,6 @@ "ShowAllNodesMessage": "Show all", "ShowOnlyAssessable": "Only assessable", "NoPreviewMessage": "No preview available.", - "errorLicenseSeatsExceeded": "The number of license seats is exceeded, you will not be able to grant editing rights to this user. Please contact your administrator." + "errorLicenseSeatsExceeded": "The number of license seats is exceeded, you will not be able to grant editing rights to this user. Please contact your administrator.", + "availableSeats": "Available seats" } diff --git a/frontend/src/lib/components/Modals/DisplayJSONModal.svelte b/frontend/src/lib/components/Modals/DisplayJSONModal.svelte index eeeeb115a..2e10174d0 100644 --- a/frontend/src/lib/components/Modals/DisplayJSONModal.svelte +++ b/frontend/src/lib/components/Modals/DisplayJSONModal.svelte @@ -1,4 +1,5 @@