diff --git a/api/audit/constants.py b/api/audit/constants.py index eddb96ef2b76..768121dfba11 100644 --- a/api/audit/constants.py +++ b/api/audit/constants.py @@ -81,3 +81,5 @@ "Phased rollout created for feature: %s by release pipeline: %s (stage: %s)" ) PHASED_ROLLOUT_STATE_UPDATED_MESSAGE = "Phased rollout split changed from '%s%%' to '%s%%' for feature '%s' by release pipeline '%s' (stage: '%s')" +PROJECT_CREATED_MESSAGE = "New Project created: %s" +PROJECT_DELETED_MESSAGE = "Project deleted: %s" diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index 53f6d0a9fbe3..5d5ce4a0f2ce 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -15,3 +15,4 @@ class RelatedObjectType(enum.Enum): WAREHOUSE_CONNECTION = "Warehouse connection" EXPERIMENT = "Experiment" METRIC = "Metric" + PROJECT = "project" diff --git a/api/projects/views.py b/api/projects/views.py index 8274dd79cd5f..fb61661bdb25 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -6,6 +6,7 @@ VIEW_PROJECT, ) from django.conf import settings +from django.db import transaction from django.utils.decorators import method_decorator from drf_spectacular.utils import extend_schema from rest_framework import status, viewsets @@ -16,6 +17,9 @@ from rest_framework.request import Request from rest_framework.response import Response +from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType from environments.dynamodb.migrator import IdentityMigrator from environments.identities.models import Identity from environments.serializers import EnvironmentSerializerLight @@ -106,10 +110,38 @@ def get_queryset(self): # type: ignore[no-untyped-def] def perform_create(self, serializer): # type: ignore[no-untyped-def] project = serializer.save() + is_master_api_key_user = getattr( + self.request.user, "is_master_api_key_user", False + ) if getattr(self.request.user, "is_master_api_key_user", False) is False: UserProjectPermission.objects.create( # type: ignore[misc] user=self.request.user, project=project, admin=True ) + AuditLog.objects.create( + project=project, + author=None if is_master_api_key_user else self.request.user, + master_api_key=self.request.user.key if is_master_api_key_user else None, + related_object_id=project.id, + related_object_type=RelatedObjectType.PROJECT.name, + log=PROJECT_CREATED_MESSAGE % project.name, + ) + + def perform_destroy(self, instance): + with transaction.atomic(): + is_master_api_key_user = getattr( + self.request.user, "is_master_api_key_user", False + ) + AuditLog.objects.create( + project=None, + author=None if is_master_api_key_user else self.request.user, + master_api_key=self.request.user.key + if is_master_api_key_user + else None, + related_object_id=instance.id, + related_object_type=RelatedObjectType.PROJECT.name, + log=PROJECT_DELETED_MESSAGE % instance.name, + ) + instance.delete() @action( detail=False, diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index cff455eef308..10c2ab150a4f 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -17,6 +17,9 @@ from rest_framework.test import APIClient from task_processor.task_run_method import TaskRunMethod +from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType from environments.dynamodb.types import ProjectIdentityMigrationStatus from environments.identities.models import Identity from features.models import Feature, FeatureSegment @@ -1069,3 +1072,44 @@ def test_list_projects__default_enforce_feature_owners__returns_false( assert len(response.json()) > 0 assert "enforce_feature_owners" in response.json()[0] assert response.json()[0]["enforce_feature_owners"] is False + + +def test_create_project_creates_audit_log(admin_client, organisation): + # Given + url = reverse("api-v1:projects:project-list") + project_name = "New Audit Log Project" + data = {"name": project_name, "organisation": organisation.id} + initial_audit_log_count = AuditLog.objects.count() + + # When + response = admin_client.post(url, data=data) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert AuditLog.objects.count() == initial_audit_log_count + 1 + + # Verify the audit log details + audit_log = AuditLog.objects.order_by("-created_date").first() + assert audit_log.related_object_type == RelatedObjectType.PROJECT.name + assert audit_log.log == PROJECT_CREATED_MESSAGE % project_name + assert audit_log.project_id == response.data["id"] + + +def test_delete_project_creates_audit_log(admin_client, project, organisation): + # Given + url = reverse("api-v1:projects:project-detail", args=[project.id]) + project_name = project.name + initial_audit_log_count = AuditLog.objects.count() + + # When + response = admin_client.delete(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert AuditLog.objects.count() == initial_audit_log_count + 1 + + # Verify the audit log details + audit_log = AuditLog.objects.order_by("-created_date").first() + assert audit_log.related_object_type == RelatedObjectType.PROJECT.name + assert audit_log.log == PROJECT_DELETED_MESSAGE % project_name + assert audit_log.related_object_id == project.id