From 41b2a6319174895cf6c6d330a820bc1f83709efd Mon Sep 17 00:00:00 2001 From: Jeroen Dekkers Date: Mon, 23 Sep 2024 16:57:09 +0200 Subject: [PATCH 1/4] Add REST API for recalculating bits and cloning katalogus settings --- rocky/tests/test_api_organization.py | 55 ++++++++++++++++++++++++++++ rocky/tools/permissions.py | 13 +++++++ rocky/tools/serializers.py | 5 +++ rocky/tools/viewsets.py | 27 +++++++++++++- 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 rocky/tools/permissions.py diff --git a/rocky/tests/test_api_organization.py b/rocky/tests/test_api_organization.py index e5b3cdae874..80df1e4df9c 100644 --- a/rocky/tests/test_api_organization.py +++ b/rocky/tests/test_api_organization.py @@ -12,6 +12,7 @@ Returns200, Returns201, Returns204, + Returns400, Returns403, Returns409, Returns500, @@ -363,3 +364,57 @@ class TestIndemnificationAlreadyExists(APIViewTest, UsesPostMethod, Returns409): def test_it_returns_indemnification(self, json, superuser_member): expected = {"indemnification": True, "user": superuser_member.user.id} assert json == expected + + +class TestRecalculateBits(APIViewTest, UsesPostMethod, Returns200): + url = lambda_fixture(lambda organization: reverse("organization-recalculate-bits", args=[organization.pk])) + + @pytest.fixture + def client(self, drf_redteam_client, redteamuser): + redteamuser.user_permissions.set([Permission.objects.get(codename="can_recalculate_bits")]) + return drf_redteam_client + + @pytest.fixture(autouse=True) + def mock_octopoes(self, mocker): + return mocker.patch("tools.viewsets.OctopoesAPIConnector.recalculate_bits", return_value=42) + + def test_it_recalculates_bits(self, json): + expected = {"number_of_bits": 42} + assert json == expected + + +class TestRecalculateBitsNoPermission(APIViewTest, UsesPostMethod, Returns403): + url = lambda_fixture(lambda organization: reverse("organization-recalculate-bits", args=[organization.pk])) + client = lambda_fixture("drf_redteam_client") + + +class TestKatalogusCloneSettings(APIViewTest, UsesPostMethod, Returns200): + url = lambda_fixture(lambda organization: reverse("organization-clone-katalogus-settings", args=[organization.pk])) + data = lambda_fixture(lambda organization_b: {"to_organization": organization_b.id}) + + @pytest.fixture + def client(self, drf_redteam_client, redteamuser): + redteamuser.user_permissions.set([Permission.objects.get(codename="can_set_katalogus_settings")]) + return drf_redteam_client + + @pytest.fixture(autouse=True) + def mock_katalogus(self, mocker): + return mocker.patch("katalogus.client.KATalogusClientV1") + + def test_it_clones_settings(self, mock_katalogus, organization_b): + mock_katalogus().clone_all_configuration_to_organization.assert_called_once_with(organization_b.code) + + +class TestCloneKatalogusSettingsNoPermission(APIViewTest, UsesPostMethod, Returns403): + url = lambda_fixture(lambda organization: reverse("organization-clone-katalogus-settings", args=[organization.pk])) + client = lambda_fixture("drf_redteam_client") + + +class TestCloneKatalogusSettingsInvalidData(APIViewTest, UsesPostMethod, Returns400): + url = lambda_fixture(lambda organization: reverse("organization-clone-katalogus-settings", args=[organization.pk])) + data = lambda_fixture(lambda organization_b: {"wrong_field": organization_b.id}) + + @pytest.fixture + def client(self, drf_redteam_client, redteamuser): + redteamuser.user_permissions.set([Permission.objects.get(codename="can_set_katalogus_settings")]) + return drf_redteam_client diff --git a/rocky/tools/permissions.py b/rocky/tools/permissions.py new file mode 100644 index 00000000000..5cd2169d32f --- /dev/null +++ b/rocky/tools/permissions.py @@ -0,0 +1,13 @@ +from rest_framework.permissions import BasePermission + + +# This is a bit clunky, but DRF doesn't allow you to specify a permission +# directly, only a Permission class +class CanRecalculateBits(BasePermission): + def has_permission(self, request, view) -> bool: + return request.user.has_perm("tools.can_recalculate_bits") + + +class CanSetKatalogusSettings(BasePermission): + def has_permission(self, request, view) -> bool: + return request.user.has_perm("tools.can_set_katalogus_settings") diff --git a/rocky/tools/serializers.py b/rocky/tools/serializers.py index 5213dc91246..7b24d11c0ee 100644 --- a/rocky/tools/serializers.py +++ b/rocky/tools/serializers.py @@ -1,3 +1,4 @@ +from rest_framework.serializers import PrimaryKeyRelatedField, Serializer from tagulous.contrib.drf import TagSerializer from tools.models import Organization @@ -14,3 +15,7 @@ class Meta: model = Organization fields = ["id", "name", "code", "tags"] read_only_fields = ["code"] + + +class ToOrganizationSerializer(Serializer): + to_organization = PrimaryKeyRelatedField(queryset=Organization.objects.all()) diff --git a/rocky/tools/viewsets.py b/rocky/tools/viewsets.py index 2189e72c68e..9584c490bb7 100644 --- a/rocky/tools/viewsets.py +++ b/rocky/tools/viewsets.py @@ -1,10 +1,14 @@ +from django.conf import settings +from katalogus.client import get_katalogus from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response +from octopoes.connector.octopoes import OctopoesAPIConnector from tools.models import Indemnification, Organization -from tools.serializers import OrganizationSerializer, OrganizationSerializerReadOnlyCode +from tools.permissions import CanRecalculateBits, CanSetKatalogusSettings +from tools.serializers import OrganizationSerializer, OrganizationSerializerReadOnlyCode, ToOrganizationSerializer class OrganizationViewSet(viewsets.ModelViewSet): @@ -50,3 +54,24 @@ def set_indemnification(self, request, pk=None): indemnification = Indemnification.objects.create(organization=organization, user=self.request.user) return Response({"indemnification": True, "user": indemnification.user.pk}, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], permission_classes=[CanRecalculateBits]) + def recalculate_bits(self, request, pk=None): + organization = self.get_object() + connector = OctopoesAPIConnector(settings.OCTOPOES_API, organization.code) + number_of_bits = connector.recalculate_bits() + + return Response({"number_of_bits": number_of_bits}) + + @action(detail=True, methods=["post"], permission_classes=[CanSetKatalogusSettings]) + def clone_katalogus_settings(self, request, pk=None): + from_organization = self.get_object() + + serializer = ToOrganizationSerializer(data=request.data) + if serializer.is_valid(): + to_organization = serializer.validated_data["to_organization"] + get_katalogus(from_organization.code).clone_all_configuration_to_organization(to_organization.code) + + return Response() + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) From b42d841410c7b01b2b51ef0b06b9d7b36f6571a2 Mon Sep 17 00:00:00 2001 From: Jeroen Dekkers Date: Mon, 30 Sep 2024 13:23:04 +0200 Subject: [PATCH 2/4] Add audit logging --- rocky/katalogus/views/katalogus_settings.py | 4 ++++ rocky/rocky/views/organization_list.py | 4 ++++ rocky/rocky/views/organization_settings.py | 4 ++++ rocky/tools/viewsets.py | 10 ++++++++++ 4 files changed, 22 insertions(+) diff --git a/rocky/katalogus/views/katalogus_settings.py b/rocky/katalogus/views/katalogus_settings.py index 14edf7a5719..be4810c03e8 100644 --- a/rocky/katalogus/views/katalogus_settings.py +++ b/rocky/katalogus/views/katalogus_settings.py @@ -9,10 +9,13 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import FormView, TemplateView from httpx import HTTPError +from structlog import get_logger from tools.models import Organization from katalogus.client import get_katalogus +logger = get_logger(__name__) + class ConfirmCloneSettingsView( OrganizationPermissionRequiredMixin, @@ -35,6 +38,7 @@ def get_context_data(self, **kwargs): def post(self, request, *args, **kwargs): to_organization = Organization.objects.get(code=kwargs["to_organization"]) + logger.info("Cloning organization settings", event_code=910000, to_organization_code=to_organization.code) get_katalogus(self.organization.code).clone_all_configuration_to_organization(to_organization.code) messages.add_message( self.request, diff --git a/rocky/rocky/views/organization_list.py b/rocky/rocky/views/organization_list.py index 596637bd82c..526875abffa 100644 --- a/rocky/rocky/views/organization_list.py +++ b/rocky/rocky/views/organization_list.py @@ -9,11 +9,14 @@ from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest from django.utils.translation import gettext_lazy as _ from django.views.generic import ListView +from structlog import get_logger from tools.models import Organization from tools.view_helpers import OrganizationBreadcrumbsMixin from octopoes.connector.octopoes import OctopoesAPIConnector +logger = get_logger(__name__) + class OrganizationListView( OrganizationBreadcrumbsMixin, @@ -40,6 +43,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: start_time = datetime.now() for organization in organizations: try: + logger.info("Recalculating bits", event_code=920000, organization_code=organization.code) number_of_bits += OctopoesAPIConnector(settings.OCTOPOES_API, organization.code).recalculate_bits() except Exception as exc: failed.append(f"{organization}, ({str(exc)})") diff --git a/rocky/rocky/views/organization_settings.py b/rocky/rocky/views/organization_settings.py index 838c07f500a..1d4b2676516 100644 --- a/rocky/rocky/views/organization_settings.py +++ b/rocky/rocky/views/organization_settings.py @@ -7,8 +7,11 @@ from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest from django.utils.translation import gettext_lazy as _ from django.views.generic import TemplateView +from structlog import get_logger from tools.view_helpers import OrganizationDetailBreadcrumbsMixin +logger = get_logger(__name__) + class PageActions(Enum): RECALCULATE = "recalculate" @@ -26,6 +29,7 @@ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: if not self.request.user.has_perm("tools.can_recalculate_bits"): raise PermissionDenied() if action == PageActions.RECALCULATE.value: + logger.info("Recalculating bits", event_code=920000) connector = self.octopoes_api_connector start_time = datetime.now() diff --git a/rocky/tools/viewsets.py b/rocky/tools/viewsets.py index 9584c490bb7..66d94ee81c7 100644 --- a/rocky/tools/viewsets.py +++ b/rocky/tools/viewsets.py @@ -4,12 +4,15 @@ from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response +from structlog import get_logger from octopoes.connector.octopoes import OctopoesAPIConnector from tools.models import Indemnification, Organization from tools.permissions import CanRecalculateBits, CanSetKatalogusSettings from tools.serializers import OrganizationSerializer, OrganizationSerializerReadOnlyCode, ToOrganizationSerializer +logger = get_logger(__name__) + class OrganizationViewSet(viewsets.ModelViewSet): queryset = Organization.objects.all() @@ -58,6 +61,7 @@ def set_indemnification(self, request, pk=None): @action(detail=True, methods=["post"], permission_classes=[CanRecalculateBits]) def recalculate_bits(self, request, pk=None): organization = self.get_object() + logger.info("Recalculating bits", event_code=920000, organization_code=self.organization.code) connector = OctopoesAPIConnector(settings.OCTOPOES_API, organization.code) number_of_bits = connector.recalculate_bits() @@ -70,6 +74,12 @@ def clone_katalogus_settings(self, request, pk=None): serializer = ToOrganizationSerializer(data=request.data) if serializer.is_valid(): to_organization = serializer.validated_data["to_organization"] + logger.info( + "Cloning organization settings", + event_code=910000, + organization_code=self.organization.code, + to_organization_code=to_organization.code, + ) get_katalogus(from_organization.code).clone_all_configuration_to_organization(to_organization.code) return Response() From 41e3aca15c26c63fba427672a2dcc5d095576ce6 Mon Sep 17 00:00:00 2001 From: Jeroen Dekkers Date: Mon, 30 Sep 2024 16:54:37 +0200 Subject: [PATCH 3/4] Update rocky/tools/viewsets.py Co-authored-by: Jan Klopper --- rocky/tools/viewsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocky/tools/viewsets.py b/rocky/tools/viewsets.py index 66d94ee81c7..949691075dc 100644 --- a/rocky/tools/viewsets.py +++ b/rocky/tools/viewsets.py @@ -77,7 +77,7 @@ def clone_katalogus_settings(self, request, pk=None): logger.info( "Cloning organization settings", event_code=910000, - organization_code=self.organization.code, + organization_code=from_organization.code, to_organization_code=to_organization.code, ) get_katalogus(from_organization.code).clone_all_configuration_to_organization(to_organization.code) From 1bc4f98c07eec1cca592eb3275fa71904252f276 Mon Sep 17 00:00:00 2001 From: Jeroen Dekkers Date: Mon, 30 Sep 2024 23:21:58 +0200 Subject: [PATCH 4/4] Fix logging statement --- rocky/tools/viewsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocky/tools/viewsets.py b/rocky/tools/viewsets.py index 949691075dc..429d70df184 100644 --- a/rocky/tools/viewsets.py +++ b/rocky/tools/viewsets.py @@ -61,7 +61,7 @@ def set_indemnification(self, request, pk=None): @action(detail=True, methods=["post"], permission_classes=[CanRecalculateBits]) def recalculate_bits(self, request, pk=None): organization = self.get_object() - logger.info("Recalculating bits", event_code=920000, organization_code=self.organization.code) + logger.info("Recalculating bits", event_code=920000, organization_code=organization.code) connector = OctopoesAPIConnector(settings.OCTOPOES_API, organization.code) number_of_bits = connector.recalculate_bits()