Skip to content

Commit

Permalink
feat: add audit log when environment feature version is published (#4064
Browse files Browse the repository at this point in the history
)
  • Loading branch information
matthewelwell committed May 31, 2024
1 parent 8b73b5c commit 88cfc76
Show file tree
Hide file tree
Showing 12 changed files with 313 additions and 7 deletions.
2 changes: 2 additions & 0 deletions api/audit/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,6 @@
CHANGE_REQUEST_COMMITTED_MESSAGE = "Change Request: %s committed"
CHANGE_REQUEST_DELETED_MESSAGE = "Change Request: %s deleted"

ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE = "New version published for feature: %s"

DATETIME_FORMAT = "%d/%m/%Y %H:%M:%S"
1 change: 1 addition & 0 deletions api/audit/related_object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ class RelatedObjectType(enum.Enum):
CHANGE_REQUEST = "Change request"
EDGE_IDENTITY = "Edge Identity"
IMPORT_REQUEST = "Import request"
EF_VERSION = "Environment feature version"
9 changes: 8 additions & 1 deletion api/audit/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,16 @@ def create_segment_priorities_changed_audit_log(
if not feature_segments:
return

# all feature segments should have the same value for feature and environment
# all feature segments should have the same value for feature, environment and
# environment feature version
environment = feature_segments[0].environment
feature = feature_segments[0].feature
environment_feature_version_id = feature_segments[0].environment_feature_version_id

if environment_feature_version_id is not None:
# Don't create audit logs for FeatureSegments wrapped in a version
# as this is handled by the feature history instead.
return

AuditLog.objects.create(
log=f"Segment overrides re-ordered for feature '{feature.name}'.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 3.2.25 on 2024-05-31 12:11

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('api_keys', '0003_masterapikey_is_admin'),
('feature_versioning', '0001_add_environment_feature_state_version_logic'),
]

operations = [
migrations.AddField(
model_name='environmentfeatureversion',
name='created_by_api_key',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_environment_feature_versions', to='api_keys.masterapikey'),
),
migrations.AddField(
model_name='environmentfeatureversion',
name='published_by_api_key',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='published_environment_feature_versions', to='api_keys.masterapikey'),
),
migrations.AddField(
model_name='historicalenvironmentfeatureversion',
name='created_by_api_key',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='api_keys.masterapikey'),
),
migrations.AddField(
model_name='historicalenvironmentfeatureversion',
name='published_by_api_key',
field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='api_keys.masterapikey'),
),
]
23 changes: 23 additions & 0 deletions api/features/versioning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.db.models import Index
from django.utils import timezone

from api_keys.models import MasterAPIKey
from features.versioning.exceptions import FeatureVersioningError
from features.versioning.managers import EnvironmentFeatureVersionManager
from features.versioning.signals import environment_feature_version_published
Expand Down Expand Up @@ -47,13 +48,28 @@ class EnvironmentFeatureVersion(
null=True,
blank=True,
)
created_by_api_key = models.ForeignKey(
"api_keys.MasterAPIKey",
related_name="created_environment_feature_versions",
on_delete=models.SET_NULL,
null=True,
blank=True,
)

published_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="published_environment_feature_versions",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
published_by_api_key = models.ForeignKey(
"api_keys.MasterAPIKey",
related_name="published_environment_feature_versions",
on_delete=models.SET_NULL,
null=True,
blank=True,
)

change_request = models.ForeignKey(
"workflows_core.ChangeRequest",
Expand Down Expand Up @@ -118,14 +134,21 @@ def get_previous_version(self) -> typing.Optional["EnvironmentFeatureVersion"]:
def publish(
self,
published_by: typing.Union["FFAdminUser", None] = None,
published_by_api_key: MasterAPIKey | None = None,
live_from: datetime.datetime | None = None,
persist: bool = True,
) -> None:
assert not (
published_by and published_by_api_key
), "Version must be published by either a user or a MasterAPIKey"

now = timezone.now()

self.live_from = live_from or (self.live_from or now)
self.published_at = now
self.published_by = published_by
self.published_by_api_key = published_by_api_key

if persist:
self.save()
environment_feature_version_published.send(self.__class__, instance=self)
Expand Down
4 changes: 2 additions & 2 deletions api/features/versioning/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

class EnvironmentFeatureVersionPermissions(BasePermission):
def has_permission(self, request: Request, view: GenericViewSet) -> bool:
if view.action == "list":
# permissions for listing handled in view.get_queryset
if view.action in ("list", "retrieve"):
# permissions for listing and retrieving handled in view.get_queryset
return True

environment_pk = view.kwargs["environment_pk"]
Expand Down
14 changes: 13 additions & 1 deletion api/features/versioning/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
from environments.tasks import rebuild_environment_document
from features.versioning.models import EnvironmentFeatureVersion
from features.versioning.signals import environment_feature_version_published
from features.versioning.tasks import trigger_update_version_webhooks
from features.versioning.tasks import (
create_environment_feature_version_published_audit_log_task,
trigger_update_version_webhooks,
)


@receiver(post_save, sender=EnvironmentFeatureVersion)
Expand Down Expand Up @@ -50,3 +53,12 @@ def trigger_webhooks(instance: EnvironmentFeatureVersion, **kwargs) -> None:
kwargs={"environment_feature_version_uuid": str(instance.uuid)},
delay_until=instance.live_from,
)


@receiver(environment_feature_version_published, sender=EnvironmentFeatureVersion)
def create_environment_feature_version_published_audit_log(
instance: EnvironmentFeatureVersion, **kwargs
) -> None:
create_environment_feature_version_published_audit_log_task.delay(
kwargs={"environment_feature_version_uuid": str(instance.uuid)}
)
33 changes: 31 additions & 2 deletions api/features/versioning/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from rest_framework import serializers

from api_keys.user import APIKeyUser
from features.serializers import CreateSegmentOverrideFeatureStateSerializer
from features.versioning.models import EnvironmentFeatureVersion
from integrations.github.github import call_github_task
Expand Down Expand Up @@ -64,16 +65,44 @@ class Meta:
)


class EnvironmentFeatureVersionRetrieveSerializer(EnvironmentFeatureVersionSerializer):
previous_version_uuid = serializers.SerializerMethodField()

class Meta(EnvironmentFeatureVersionSerializer.Meta):
_fields = ("previous_version_uuid",)

fields = EnvironmentFeatureVersionSerializer.Meta.fields + _fields

def get_previous_version_uuid(
self, environment_feature_version: EnvironmentFeatureVersion
) -> str | None:
previous_version = environment_feature_version.get_previous_version()
if not previous_version:
return None
return str(previous_version.uuid)


class EnvironmentFeatureVersionPublishSerializer(serializers.Serializer):
live_from = serializers.DateTimeField(required=False)

def save(self, **kwargs):
live_from = self.validated_data.get("live_from")

request = self.context["request"]
published_by = request.user if isinstance(request.user, FFAdminUser) else None

self.instance.publish(live_from=live_from, published_by=published_by)
published_by = None
published_by_api_key = None

if isinstance(request.user, FFAdminUser):
published_by = request.user
elif isinstance(request.user, APIKeyUser):
published_by_api_key = request.user.key

self.instance.publish(
live_from=live_from,
published_by=published_by,
published_by_api_key=published_by_api_key,
)
return self.instance


Expand Down
22 changes: 22 additions & 0 deletions api/features/versioning/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from django.utils import timezone

from audit.constants import ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE
from audit.models import AuditLog
from audit.related_object_type import RelatedObjectType
from features.models import FeatureState
from features.versioning.models import EnvironmentFeatureVersion
from features.versioning.schemas import (
Expand Down Expand Up @@ -131,3 +134,22 @@ def trigger_update_version_webhooks(environment_feature_version_uuid: str) -> No
data=data,
event_type=WebhookEventType.NEW_VERSION_PUBLISHED,
)


@register_task_handler()
def create_environment_feature_version_published_audit_log_task(
environment_feature_version_uuid: str,
) -> None:
environment_feature_version = EnvironmentFeatureVersion.objects.select_related(
"environment", "feature"
).get(uuid=environment_feature_version_uuid)

AuditLog.objects.create(
environment=environment_feature_version.environment,
related_object_type=RelatedObjectType.EF_VERSION.name,
related_object_uuid=environment_feature_version.uuid,
log=ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE
% environment_feature_version.feature.name,
author_id=environment_feature_version.published_by_id,
master_api_key_id=environment_feature_version.published_by_api_key_id,
)
6 changes: 5 additions & 1 deletion api/features/versioning/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CreateModelMixin,
DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
)
from rest_framework.permissions import IsAuthenticated
Expand All @@ -30,6 +31,7 @@
EnvironmentFeatureVersionFeatureStateSerializer,
EnvironmentFeatureVersionPublishSerializer,
EnvironmentFeatureVersionQuerySerializer,
EnvironmentFeatureVersionRetrieveSerializer,
EnvironmentFeatureVersionSerializer,
)
from projects.permissions import VIEW_PROJECT
Expand All @@ -44,11 +46,11 @@
)
class EnvironmentFeatureVersionViewSet(
GenericViewSet,
RetrieveModelMixin,
ListModelMixin,
CreateModelMixin,
DestroyModelMixin,
):
serializer_class = EnvironmentFeatureVersionSerializer
permission_classes = [IsAuthenticated, EnvironmentFeatureVersionPermissions]

def __init__(self, *args, **kwargs):
Expand All @@ -62,6 +64,8 @@ def get_serializer_class(self):
match self.action:
case "publish":
return EnvironmentFeatureVersionPublishSerializer
case "retrieve":
return EnvironmentFeatureVersionRetrieveSerializer
case _:
return EnvironmentFeatureVersionSerializer

Expand Down
51 changes: 51 additions & 0 deletions api/tests/unit/audit/test_unit_audit_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from environments.models import Environment
from features.models import Feature, FeatureSegment, FeatureState
from features.versioning.tasks import enable_v2_versioning
from segments.models import Segment
from users.models import FFAdminUser

Expand Down Expand Up @@ -251,6 +252,56 @@ def test_create_segment_priorities_changed_audit_log(
).exists()


def test_create_segment_priorities_changed_audit_log_does_not_create_audit_log_for_versioned_feature_segments(
admin_user: FFAdminUser,
feature_segment: FeatureSegment,
feature: Feature,
segment_featurestate: FeatureState,
environment: Environment,
) -> None:
# Given
another_segment = Segment.objects.create(
project=environment.project, name="Another Segment"
)
another_feature_segment = FeatureSegment.objects.create(
feature=feature,
environment=environment,
segment=another_segment,
)
FeatureState.objects.create(
feature=feature,
environment=environment,
feature_segment=another_feature_segment,
)

now = timezone.now()

enable_v2_versioning(environment.id)

feature_segment.refresh_from_db()
another_feature_segment.refresh_from_db()
assert feature_segment.environment_feature_version_id is not None
assert another_feature_segment.environment_feature_version_id is not None

# When
create_segment_priorities_changed_audit_log(
previous_id_priority_pairs=[
(feature_segment.id, 0),
(another_feature_segment.id, 1),
],
feature_segment_ids=[feature_segment.id, another_feature_segment.id],
user_id=admin_user.id,
changed_at=now.isoformat(),
)

# Then
assert not AuditLog.objects.filter(
environment=environment,
log=f"Segment overrides re-ordered for feature '{feature.name}'.",
created_date=now,
).exists()


def test_create_feature_state_went_live_audit_log(
change_request_feature_state: FeatureState,
) -> None:
Expand Down
Loading

0 comments on commit 88cfc76

Please sign in to comment.