Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add audit log when environment feature version is published #4064

Merged
merged 5 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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