From 8f7616cf04ae2d827afa7261ddb313fb0dcf3af6 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 06:30:30 +0000 Subject: [PATCH 01/11] Add audit logs for project creation and deletion --- api/audit/constants.py | 2 + api/audit/related_object_type.py | 2 + api/projects/views.py | 22 +++++++++- .../unit/projects/test_unit_projects_views.py | 44 ++++++++++++++++++- 4 files changed, 67 insertions(+), 3 deletions(-) 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..f2f3838280a1 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -15,3 +15,5 @@ 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..1aa8e7a6217a 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -49,7 +49,9 @@ ProjectUpdateSerializer, ) from users.models import FFAdminUser - +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE @method_decorator( name="retrieve", @@ -110,7 +112,23 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] UserProjectPermission.objects.create( # type: ignore[misc] user=self.request.user, project=project, admin=True ) - + AuditLog.objects.create( + project=project, + author=self.request.user if not getattr(self.request.user,"is_master_api_key_user",False) else None, + related_object_id=project.id, + related_object_type=RelatedObjectType.PROJECT.name, + log=PROJECT_CREATED_MESSAGE % project.name + ) + + def perform_destroy(self, instance): + AuditLog.objects.create( + project=instance, + author=self.request.user if not getattr(self.request.user,"is_master_api_key_user",False) else None, + related_object_id=instance.id, + related_object_type=RelatedObjectType.PROJECT.name, + log=PROJECT_DELETED_MESSAGE % instance.name + ) + instance.delete() @action( detail=False, url_path=r"get-by-uuid/(?P[0-9a-f-]+)", diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index cff455eef308..59250bc71cad 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -1,6 +1,8 @@ import json from datetime import timedelta - +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE import pytest from common.projects.permissions import ( CREATE_ENVIRONMENT, @@ -1069,3 +1071,43 @@ 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 \ No newline at end of file From 69d94a301e88bb6248bae2e81afabd6ccf8b49ee Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:33:25 +0000 Subject: [PATCH 02/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/audit/related_object_type.py | 1 - api/projects/views.py | 26 ++++++++++++------- .../unit/projects/test_unit_projects_views.py | 17 ++++++------ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index f2f3838280a1..5d5ce4a0f2ce 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -16,4 +16,3 @@ class RelatedObjectType(enum.Enum): EXPERIMENT = "Experiment" METRIC = "Metric" PROJECT = "project" - diff --git a/api/projects/views.py b/api/projects/views.py index 1aa8e7a6217a..923a6afad814 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -16,6 +16,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 @@ -49,9 +52,7 @@ ProjectUpdateSerializer, ) from users.models import FFAdminUser -from audit.models import AuditLog -from audit.related_object_type import RelatedObjectType -from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE + @method_decorator( name="retrieve", @@ -114,21 +115,26 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] ) AuditLog.objects.create( project=project, - author=self.request.user if not getattr(self.request.user,"is_master_api_key_user",False) else None, + author=self.request.user + if not getattr(self.request.user, "is_master_api_key_user", False) + else None, related_object_id=project.id, related_object_type=RelatedObjectType.PROJECT.name, - log=PROJECT_CREATED_MESSAGE % project.name + log=PROJECT_CREATED_MESSAGE % project.name, ) - + def perform_destroy(self, instance): - AuditLog.objects.create( + AuditLog.objects.create( project=instance, - author=self.request.user if not getattr(self.request.user,"is_master_api_key_user",False) else None, + author=self.request.user + if not getattr(self.request.user, "is_master_api_key_user", False) + else None, related_object_id=instance.id, related_object_type=RelatedObjectType.PROJECT.name, - log=PROJECT_DELETED_MESSAGE % instance.name + log=PROJECT_DELETED_MESSAGE % instance.name, ) - instance.delete() + instance.delete() + @action( detail=False, url_path=r"get-by-uuid/(?P[0-9a-f-]+)", diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index 59250bc71cad..4c3cb38546da 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -1,8 +1,6 @@ import json from datetime import timedelta -from audit.models import AuditLog -from audit.related_object_type import RelatedObjectType -from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE + import pytest from common.projects.permissions import ( CREATE_ENVIRONMENT, @@ -19,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 @@ -1071,8 +1072,8 @@ 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") @@ -1086,7 +1087,7 @@ def test_create_project_creates_audit_log(admin_client, organisation): # 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 @@ -1106,8 +1107,8 @@ def test_delete_project_creates_audit_log(admin_client, project, organisation): # 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 \ No newline at end of file + assert audit_log.log == PROJECT_DELETED_MESSAGE % project_name From 8180c4fa8339d960b0bfb0cce76a7ab569958bfb Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 12:16:19 +0530 Subject: [PATCH 03/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/projects/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/projects/views.py b/api/projects/views.py index 923a6afad814..dba1d36c4db1 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -124,11 +124,11 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] ) def perform_destroy(self, instance): + is_master_api_key_user = getattr(self.request.user, "is_master_api_key_user", False) AuditLog.objects.create( project=instance, - author=self.request.user - if not getattr(self.request.user, "is_master_api_key_user", False) - else 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, From 818d84237be6e22d8601cc3d46d720653fe312c1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:46:33 +0000 Subject: [PATCH 04/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/projects/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/projects/views.py b/api/projects/views.py index dba1d36c4db1..c5ab611bf61c 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -124,7 +124,9 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] ) def perform_destroy(self, instance): - is_master_api_key_user = getattr(self.request.user, "is_master_api_key_user", False) + is_master_api_key_user = getattr( + self.request.user, "is_master_api_key_user", False + ) AuditLog.objects.create( project=instance, author=None if is_master_api_key_user else self.request.user, From b48cf12b9873eb28ee181c709d35c20995bcf060 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 12:20:49 +0530 Subject: [PATCH 05/11] Update api/tests/unit/projects/test_unit_projects_views.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- api/tests/unit/projects/test_unit_projects_views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/tests/unit/projects/test_unit_projects_views.py b/api/tests/unit/projects/test_unit_projects_views.py index 4c3cb38546da..10c2ab150a4f 100644 --- a/api/tests/unit/projects/test_unit_projects_views.py +++ b/api/tests/unit/projects/test_unit_projects_views.py @@ -1112,3 +1112,4 @@ def test_delete_project_creates_audit_log(admin_client, project, organisation): 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 From 4ed9a860e032df57351e6fd356a8e6b1dd2121f7 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 07:00:00 +0000 Subject: [PATCH 06/11] Wrap project deletion in a transaction to ensure atomicity --- api/projects/views.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/api/projects/views.py b/api/projects/views.py index c5ab611bf61c..68aa04900983 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -15,6 +15,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response +from django.db import transaction from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE from audit.models import AuditLog @@ -124,18 +125,19 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] ) def perform_destroy(self, instance): - is_master_api_key_user = getattr( - self.request.user, "is_master_api_key_user", False - ) - AuditLog.objects.create( - project=instance, - 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() + with transaction.atomic(): + is_master_api_key_user = getattr( + self.request.user, "is_master_api_key_user", False + ) + AuditLog.objects.create( + project=instance, + 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, From 5052768a17d6c1ce25db7d871b2585c9054a7beb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:00:34 +0000 Subject: [PATCH 07/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/projects/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api/projects/views.py b/api/projects/views.py index 68aa04900983..02b800d88b67 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 @@ -15,7 +16,6 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from django.db import transaction from audit.constants import PROJECT_CREATED_MESSAGE, PROJECT_DELETED_MESSAGE from audit.models import AuditLog @@ -132,7 +132,9 @@ def perform_destroy(self, instance): AuditLog.objects.create( project=instance, 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, + 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, From 66779e21048ceb852918abb73c3f43fee8bfd490 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 07:10:08 +0000 Subject: [PATCH 08/11] Refactor AuditLog author assignment for master API key users --- api/projects/views.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api/projects/views.py b/api/projects/views.py index 02b800d88b67..287a4eccea41 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -116,9 +116,8 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] ) AuditLog.objects.create( project=project, - author=self.request.user - if not getattr(self.request.user, "is_master_api_key_user", False) - else 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, else None, related_object_id=project.id, related_object_type=RelatedObjectType.PROJECT.name, log=PROJECT_CREATED_MESSAGE % project.name, From 183729afba02251f5db5f044fd965d4c9b5b822c Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 07:13:28 +0000 Subject: [PATCH 09/11] Fix syntax error in AuditLog creation for master API key users --- api/projects/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/projects/views.py b/api/projects/views.py index 287a4eccea41..3d3e2729c68e 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -117,7 +117,7 @@ def perform_create(self, serializer): # type: ignore[no-untyped-def] 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, else None, + 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, From 675f5a7cd4b81afa631edbaf555fb0f56e64c430 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 07:18:55 +0000 Subject: [PATCH 10/11] Add check for master API key user in project creation --- api/projects/views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/projects/views.py b/api/projects/views.py index 3d3e2729c68e..412c37c877a1 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -110,6 +110,9 @@ 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 From d1c25f8e450e52d7886ae7e15c13b2ab0942f406 Mon Sep 17 00:00:00 2001 From: Srijan Tripathi Date: Wed, 22 Jul 2026 07:22:51 +0000 Subject: [PATCH 11/11] Set project to None in AuditLog creation for master API key users --- api/projects/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/projects/views.py b/api/projects/views.py index 412c37c877a1..fb61661bdb25 100644 --- a/api/projects/views.py +++ b/api/projects/views.py @@ -132,7 +132,7 @@ def perform_destroy(self, instance): self.request.user, "is_master_api_key_user", False ) AuditLog.objects.create( - project=instance, + 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