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

fix: audit log integrations for versioned environments #4876

Merged
merged 10 commits into from
Dec 6, 2024
7 changes: 6 additions & 1 deletion api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,9 @@

SECURE_REDIRECT_EXEMPT = env.list("DJANGO_SECURE_REDIRECT_EXEMPT", default=[])
SECURE_REFERRER_POLICY = env.str("DJANGO_SECURE_REFERRER_POLICY", default="same-origin")
SECURE_CROSS_ORIGIN_OPENER_POLICY = env.str("DJANGO_SECURE_CROSS_ORIGIN_OPENER_POLICY", default="same-origin")
SECURE_CROSS_ORIGIN_OPENER_POLICY = env.str(
"DJANGO_SECURE_CROSS_ORIGIN_OPENER_POLICY", default="same-origin"
)
SECURE_SSL_HOST = env.str("DJANGO_SECURE_SSL_HOST", default=None)
SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=False)

Expand Down Expand Up @@ -1008,6 +1010,9 @@
"ENABLE_TASK_PROCESSOR_HEALTH_CHECK", default=False
)

# Allows us to prevent the postpone decorator from running things async
ENABLE_POSTPONE_DECORATOR = env.bool("ENABLE_POSTPONE_DECORATOR", default=True)

ENABLE_CLEAN_UP_OLD_TASKS = env.bool("ENABLE_CLEAN_UP_OLD_TASKS", default=True)
TASK_DELETE_RETENTION_DAYS = env.int("TASK_DELETE_RETENTION_DAYS", default=30)
TASK_DELETE_BATCH_SIZE = env.int("TASK_DELETE_BATCH_SIZE", default=2000)
Expand Down
2 changes: 2 additions & 0 deletions api/app/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@
RETRY_WEBHOOKS = True

INFLUXDB_BUCKET = "test_bucket"

ENABLE_POSTPONE_DECORATOR = False
1 change: 1 addition & 0 deletions api/audit/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def get_audited_instance_from_audit_log_record(
uuid=audit_log_record.related_object_uuid,
environment=audit_log_record.environment,
)
.select_related("feature")
.first()
)

Expand Down
1 change: 1 addition & 0 deletions api/audit/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def signal_wrapper(sender, instance, **kwargs):
RelatedObjectType.FEATURE.name,
RelatedObjectType.FEATURE_STATE.name,
RelatedObjectType.SEGMENT.name,
RelatedObjectType.EF_VERSION.name,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is essentially the only thing that needed to change to fix the issue. The rest of the changes are related to the tests, improving the integration for EF versions, or refactoring.

]:
return None
return signal_function(sender, instance, **kwargs)
Expand Down
71 changes: 27 additions & 44 deletions api/integrations/dynatrace/dynatrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import requests

from audit.models import AuditLog
from audit.related_object_type import RelatedObjectType
from features.models import Feature
from audit.services import get_audited_instance_from_audit_log_record
from features.models import Feature, FeatureState
from features.versioning.models import EnvironmentFeatureVersion
from integrations.common.wrapper import AbstractBaseEventIntegrationWrapper
from segments.models import Segment

Expand Down Expand Up @@ -57,45 +58,27 @@ def generate_event_data(audit_log_record: AuditLog) -> dict:


def _get_deployment_name(audit_log_record: AuditLog) -> str:
try:
related_object_type = RelatedObjectType[audit_log_record.related_object_type]

if related_object_type in (
RelatedObjectType.FEATURE,
RelatedObjectType.FEATURE_STATE,
):
return _get_deployment_name_for_feature(
audit_log_record.related_object_id, related_object_type
)
elif related_object_type == RelatedObjectType.SEGMENT:
return _get_deployment_name_for_segment(audit_log_record.related_object_id)
except KeyError:
pass

# use 'Deployment' as a fallback to maintain current behaviour in the
# event that we cannot determine the correct name to return.
return DEFAULT_DEPLOYMENT_NAME


def _get_deployment_name_for_feature(
object_id: int, object_type: RelatedObjectType
) -> str:
qs = Feature.objects.all_with_deleted()
if object_type == RelatedObjectType.FEATURE:
qs = qs.filter(id=object_id)
elif object_type == RelatedObjectType.FEATURE_STATE:
qs = qs.filter(feature_states__id=object_id).distinct()

if feature := qs.first():
return f"Flagsmith Deployment - Flag Changed: {feature.name}"

# use 'Deployment' as a fallback to maintain current behaviour in the
# event that we cannot determine the correct name to return.
return DEFAULT_DEPLOYMENT_NAME


def _get_deployment_name_for_segment(object_id: int) -> str:
if segment := Segment.live_objects.all_with_deleted().filter(id=object_id).first():
return f"Flagsmith Deployment - Segment Changed: {segment.name}"

return DEFAULT_DEPLOYMENT_NAME
audited_instance = get_audited_instance_from_audit_log_record(audit_log_record)

if isinstance(audited_instance, Feature):
deployment_name = _get_deployment_name_for_feature(audited_instance)
elif isinstance(audited_instance, FeatureState) or isinstance(
audited_instance, EnvironmentFeatureVersion
):
deployment_name = _get_deployment_name_for_feature(audited_instance.feature)
elif isinstance(audited_instance, Segment):
deployment_name = _get_deployment_name_for_segment(audited_instance)
else:
# use 'Deployment' as a fallback to maintain current behaviour in the
# event that we cannot determine the correct name to return.
deployment_name = DEFAULT_DEPLOYMENT_NAME

return deployment_name


def _get_deployment_name_for_feature(feature: Feature) -> str:
return f"Flagsmith Deployment - Flag Changed: {feature.name}"


def _get_deployment_name_for_segment(segment: Segment) -> str:
return f"Flagsmith Deployment - Segment Changed: {segment.name}"
7 changes: 7 additions & 0 deletions api/integrations/grafana/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
FeatureState,
FeatureStateValue,
)
from features.versioning.models import EnvironmentFeatureVersion
from integrations.grafana.types import GrafanaAnnotation
from segments.models import Segment

Expand Down Expand Up @@ -49,6 +50,12 @@ def _get_instance_tags_from_audit_log_record(
*_get_feature_tags(feature),
]

if isinstance(instance, EnvironmentFeatureVersion):
return [
f"feature:{instance.feature.name}",
*_get_feature_tags(instance.feature),
]

return []


Expand Down
156 changes: 146 additions & 10 deletions api/tests/unit/audit/test_unit_audit_signals.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import json

import responses
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture

Expand All @@ -9,13 +12,19 @@
send_audit_log_event_to_grafana,
)
from environments.models import Environment
from features.models import Feature
from features.versioning.models import EnvironmentFeatureVersion
from integrations.dynatrace.dynatrace import EVENTS_API_URI
from integrations.dynatrace.models import DynatraceConfiguration
from integrations.grafana.grafana import ROUTE_API_ANNOTATIONS
from integrations.grafana.models import (
GrafanaOrganisationConfiguration,
GrafanaProjectConfiguration,
)
from organisations.models import Organisation, OrganisationWebhook
from projects.models import Project
from projects.tags.models import Tag
from users.models import FFAdminUser
from webhooks.webhooks import WebhookEventType


Expand Down Expand Up @@ -99,15 +108,16 @@ def test_send_audit_log_event_to_grafana__project_grafana_config__calls_expected
project: Project,
) -> None:
# Given
grafana_config = GrafanaProjectConfiguration(base_url="test.com", api_key="test")
project.grafana_config = grafana_config
audit_log_record = AuditLog.objects.create(
project=project,
related_object_type=RelatedObjectType.FEATURE.name,
)
grafana_wrapper_mock = mocker.patch("audit.signals.GrafanaWrapper", autospec=True)
grafana_wrapper_instance_mock = grafana_wrapper_mock.return_value

grafana_config = GrafanaProjectConfiguration(base_url="test.com", api_key="test")
project.grafana_config = grafana_config
Comment on lines +118 to +119
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having added the logic to prevent the postpone decorator from spawning unmanaged threads we need to move this here to prevent the generation of the audit log record itself from triggering an external request to Grafana (or Dynatrace as the case may be below).


# When
send_audit_log_event_to_grafana(AuditLog, audit_log_record)

Expand All @@ -130,17 +140,18 @@ def test_send_audit_log_event_to_grafana__organisation_grafana_config__calls_exp
project: Project,
) -> None:
# Given
grafana_config = GrafanaOrganisationConfiguration(
base_url="test.com", api_key="test"
)
organisation.grafana_config = grafana_config
audit_log_record = AuditLog.objects.create(
project=project,
related_object_type=RelatedObjectType.FEATURE.name,
)
grafana_wrapper_mock = mocker.patch("audit.signals.GrafanaWrapper", autospec=True)
grafana_wrapper_instance_mock = grafana_wrapper_mock.return_value

grafana_config = GrafanaOrganisationConfiguration(
base_url="test.com", api_key="test"
)
organisation.grafana_config = grafana_config

# When
send_audit_log_event_to_grafana(AuditLog, audit_log_record)

Expand All @@ -157,15 +168,68 @@ def test_send_audit_log_event_to_grafana__organisation_grafana_config__calls_exp
)


@responses.activate
def test_send_environment_feature_version_audit_log_event_to_grafana(
tagged_feature: Feature,
tag_one: Tag,
tag_two: Tag,
environment_v2_versioning: Environment,
project: Project,
organisation: Organisation,
admin_user: FFAdminUser,
) -> None:
# Given
_, audit_log_record = _create_and_publish_environment_feature_version(
environment=environment_v2_versioning,
feature=tagged_feature,
user=admin_user,
)

base_url = "https://test.com"
GrafanaOrganisationConfiguration.objects.create(
base_url=base_url, api_key="test", organisation=organisation
)

responses.add(
method=responses.POST,
url=f"{base_url}{ROUTE_API_ANNOTATIONS}",
status=200,
json={
"message": "Annotation added",
"id": 1,
},
)

# When
send_audit_log_event_to_grafana(AuditLog, audit_log_record)

# Then
expected_time = int(audit_log_record.created_date.timestamp() * 1000)

assert len(responses.calls) == 1
assert responses.calls[0].request.body == json.dumps(
{
"tags": [
"flagsmith",
f"project:{project.name}",
f"environment:{environment_v2_versioning.name}",
f"by:{admin_user.email}",
f"feature:{tagged_feature.name}",
tag_one.label,
tag_two.label,
],
"text": audit_log_record.log,
"time": expected_time,
"timeEnd": expected_time,
}
)


def test_send_audit_log_event_to_dynatrace__environment_dynatrace_config__calls_expected(
mocker: MockerFixture,
environment: Environment,
) -> None:
# Given
dynatrace_config = DynatraceConfiguration.objects.create(
base_url="http://test.com", api_key="api_123", environment=environment
)
environment.refresh_from_db()
audit_log_record = AuditLog.objects.create(
environment=environment,
related_object_type=RelatedObjectType.FEATURE.name,
Expand All @@ -175,6 +239,10 @@ def test_send_audit_log_event_to_dynatrace__environment_dynatrace_config__calls_
)
dynatrace_wrapper_instance_mock = dynatrace_wrapper_mock.return_value

dynatrace_config = DynatraceConfiguration.objects.create(
base_url="http://test.com", api_key="api_123", environment=environment
)

# When
send_audit_log_event_to_dynatrace(AuditLog, audit_log_record)

Expand All @@ -190,3 +258,71 @@ def test_send_audit_log_event_to_dynatrace__environment_dynatrace_config__calls_
dynatrace_wrapper_instance_mock.track_event_async.assert_called_once_with(
event=dynatrace_wrapper_instance_mock.generate_event_data.return_value
)


@responses.activate
def test_send_environment_feature_version_audit_log_event_to_dynatrace(
feature: Feature,
environment_v2_versioning: Environment,
project: Project,
organisation: Organisation,
admin_user: FFAdminUser,
) -> None:
# Given
_, audit_log_record = _create_and_publish_environment_feature_version(
environment=environment_v2_versioning, feature=feature, user=admin_user
)

base_url = "https://dynatrace.test.com"
api_key = "api_123"
DynatraceConfiguration.objects.create(
base_url=base_url, api_key=api_key, environment=environment_v2_versioning
)

responses.add(
method=responses.POST,
url=f"{base_url}{EVENTS_API_URI}?api-token={api_key}",
status=201,
json={
"reportCount": 1,
"eventIngestResults": [{"correlationId": "foobar123456", "status": "OK"}],
},
)

# When
send_audit_log_event_to_dynatrace(AuditLog, audit_log_record)

# Then
assert len(responses.calls) == 1
assert json.loads(responses.calls[0].request.body) == {
"title": "Flagsmith flag change.",
"eventType": "CUSTOM_DEPLOYMENT",
"properties": {
"event": f"{audit_log_record.log} by user {admin_user.email}",
"environment": environment_v2_versioning.name,
"dt.event.deployment.name": f"Flagsmith Deployment - Flag Changed: {feature.name}",
},
"entitySelector": "",
}


def _create_and_publish_environment_feature_version(
environment: Environment,
feature: Feature,
user: FFAdminUser,
) -> (EnvironmentFeatureVersion, AuditLog):
version = EnvironmentFeatureVersion(
environment=environment,
feature=feature,
)
version.publish(user)

audit_log_record = (
AuditLog.objects.filter(
related_object_uuid=version.uuid,
related_object_type=RelatedObjectType.EF_VERSION.name,
)
.order_by("-created_date")
.first()
)
return version, audit_log_record
11 changes: 11 additions & 0 deletions api/tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@ def tag_two(project):
)


@pytest.fixture
def tagged_feature(
feature: Feature,
tag_one: Tag,
tag_two: Tag,
) -> Feature:
feature.tags.add(tag_one, tag_two)
feature.save()
return feature


@pytest.fixture()
def project_two(organisation: Organisation) -> Project:
return Project.objects.create(name="Test Project Two", organisation=organisation)
Expand Down
Loading
Loading