diff --git a/api/audit/migrations/0016_add_organisation_id.py b/api/audit/migrations/0016_add_organisation_id.py new file mode 100644 index 000000000000..512de4fcad13 --- /dev/null +++ b/api/audit/migrations/0016_add_organisation_id.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.17 on 2026-11-06 21:08 + +import django.db.models.deletion +from django.db import migrations, models +from django.db.backends import utils as backend_utils + +# Matches the name Django generates for a `db_index=True` index on this column +ORGANISATION_ID_INDEX_NAME = "audit_auditlog_organisation_id_" + ( + backend_utils.names_digest( # type: ignore[attr-defined] # Missing from django-stubs + "audit_auditlog", "organisation_id", length=8 + ) +) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("audit", "0015_add_log_trigram_index"), + ("organisations", "0061_add_user_organisation_is_active"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.AddField( + model_name="auditlog", + name="organisation_id", + field=models.IntegerField(null=True), + ), + migrations.RunSQL( + f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {ORGANISATION_ID_INDEX_NAME} " + "ON audit_auditlog (organisation_id)", + reverse_sql=f"DROP INDEX CONCURRENTLY IF EXISTS {ORGANISATION_ID_INDEX_NAME}", + ), + ], + state_operations=[ + migrations.AddField( + model_name="auditlog", + name="organisation", + field=models.ForeignKey( + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="audit_logs", + to="organisations.organisation", + ), + ), + ], + ), + ] diff --git a/api/audit/models.py b/api/audit/models.py index e86c191d0358..e3d92f1fdc09 100644 --- a/api/audit/models.py +++ b/api/audit/models.py @@ -17,17 +17,22 @@ from api_keys.models import MasterAPIKey from audit.related_object_type import RelatedObjectType +from organisations.models import Organisation from projects.models import Project -if typing.TYPE_CHECKING: - from organisations.models import Organisation - RELATED_OBJECT_TYPES = ((tag.name, tag.value) for tag in RelatedObjectType) class AuditLog(LifecycleModel): # type: ignore[misc] created_date = models.DateTimeField("DateCreated") # type: ignore[var-annotated] + organisation = models.ForeignKey[Organisation | None, Organisation | None]( + Organisation, + related_name="audit_logs", + null=True, + on_delete=models.DO_NOTHING, + db_constraint=False, # Logs survive on deletion + ) project = models.ForeignKey( # type: ignore[var-annotated] Project, related_name="audit_logs", null=True, on_delete=models.DO_NOTHING ) @@ -83,21 +88,6 @@ class Meta: ), ] - @property - def organisation(self) -> "Organisation | None": - # TODO properly implement organisation relation - # maybe the relation list should not be _that_ exhaustive... - for relation in ( - "project", - "environment", - "author", - "master_api_key", - "history_record", - ): - if hasattr(related_instance := getattr(self, relation), "organisation"): - return related_instance.organisation # type: ignore[no-any-return] - return None - @property def environment_document_updated(self) -> bool: if self.related_object_type == RelatedObjectType.CHANGE_REQUEST.name: diff --git a/api/audit/permissions.py b/api/audit/permissions.py index f4d17dcbeee4..83d0cc69d254 100644 --- a/api/audit/permissions.py +++ b/api/audit/permissions.py @@ -1,5 +1,7 @@ from common.projects.permissions import VIEW_AUDIT_LOG +from django.contrib.auth.models import AnonymousUser from django.views import View +from rest_framework.exceptions import NotFound from rest_framework.permissions import BasePermission from rest_framework.request import Request @@ -15,7 +17,13 @@ def has_permission(self, request: Request, view: View): # type: ignore[no-untyp except (Organisation.DoesNotExist, KeyError): return False - return request.user.is_organisation_admin(organisation) # type: ignore[union-attr] + user = request.user + assert not isinstance(user, AnonymousUser) + if user.is_organisation_admin(organisation): + return True + if not user.belongs_to(organisation.id): + raise NotFound() + return False class ProjectAuditLogPermissions(BasePermission): diff --git a/api/audit/serializers.py b/api/audit/serializers.py index a2e8110236a0..b660c21a88b4 100644 --- a/api/audit/serializers.py +++ b/api/audit/serializers.py @@ -2,14 +2,16 @@ from rest_framework import serializers from audit.models import AuditLog -from audit.types import AuditLogChangeDetail, ChangeType +from audit.types import AuditLogChangeDetail, AuditLogsQueryParams, ChangeType from environments.serializers import EnvironmentSerializerLight +from organisations.serializers import OrganisationSerializerBasic from projects.serializers import ProjectListSerializer from users.serializers import UserListSerializer class AuditLogListSerializer(serializers.ModelSerializer): # type: ignore[type-arg] author = UserListSerializer() + organisation = OrganisationSerializerBasic() environment = EnvironmentSerializerLight() project = ProjectListSerializer() @@ -20,6 +22,7 @@ class Meta: "created_date", "log", "author", + "organisation", "environment", "project", "related_object_id", @@ -37,6 +40,7 @@ class AuditLogChangeDetailsSerializer(serializers.Serializer[AuditLogChangeDetai class AuditLogRetrieveSerializer(serializers.ModelSerializer): # type: ignore[type-arg] author = UserListSerializer() + organisation = OrganisationSerializerBasic() environment = EnvironmentSerializerLight() project = ProjectListSerializer() change_details = serializers.SerializerMethodField() @@ -49,6 +53,7 @@ class Meta: "created_date", "log", "author", + "organisation", "environment", "project", "related_object_id", @@ -102,4 +107,16 @@ class AuditLogsQueryParamSerializer(serializers.Serializer): # type: ignore[typ is_system_event = serializers.BooleanField( required=False, allow_null=True, default=None ) + is_organisation_level = serializers.BooleanField( + required=False, allow_null=True, default=None + ) search = serializers.CharField(min_length=3, max_length=256, required=False) + + def validate(self, attrs: AuditLogsQueryParams) -> AuditLogsQueryParams: + if attrs.get("is_organisation_level") and ( + attrs.get("project") or attrs.get("environments") + ): + raise serializers.ValidationError( + "Organisation-level logs cannot be filtered by project or environment." + ) + return attrs diff --git a/api/audit/signals.py b/api/audit/signals.py index d77fd68448fa..c125fcb0cbe1 100644 --- a/api/audit/signals.py +++ b/api/audit/signals.py @@ -56,11 +56,12 @@ def _get_integration_config( instance: AuditLog, integration_name: AuditLogIntegrationAttrName, ) -> IntegrationsModel | None: - for relation_name in ("project", "environment", "organisation"): - if hasattr( - related_object := getattr(instance, relation_name), - integration_name, - ): + for related_object in ( + instance.project, + instance.environment, + instance.organisation or getattr(instance.project, "organisation", None), + ): + if hasattr(related_object, integration_name): integration_config: IntegrationsModel = getattr( related_object, integration_name, @@ -75,16 +76,16 @@ def call_webhooks(sender, instance, **kwargs): # type: ignore[no-untyped-def] if settings.DISABLE_WEBHOOKS: return - if not (instance.project_id or instance.environment_id): - logger.warning("Audit log without project or environment. Not sending webhook.") + if instance.organisation_id: + organisation_id = instance.organisation_id + elif instance.project: + organisation_id = instance.project.organisation_id + elif instance.environment: + organisation_id = instance.environment.project.organisation_id + else: + logger.warning("Audit log without organisation. Not sending webhook.") return - organisation_id = ( - instance.project.organisation_id - if instance.project - else instance.environment.project.organisation_id - ) - if OrganisationWebhook.objects.filter( organisation_id=organisation_id, enabled=True ).exists(): diff --git a/api/audit/types.py b/api/audit/types.py index 45790502894b..019c8f7c143e 100644 --- a/api/audit/types.py +++ b/api/audit/types.py @@ -7,3 +7,11 @@ class AuditLogChangeDetail(TypedDict): field: str old: int | float | str | bool | None new: int | float | str | bool | None + + +class AuditLogsQueryParams(TypedDict, total=False): + project: int + environments: list[int] + is_system_event: bool | None + is_organisation_level: bool | None + search: str diff --git a/api/audit/views.py b/api/audit/views.py index e5a9ef447a43..6e75a0f3df1e 100644 --- a/api/audit/views.py +++ b/api/audit/views.py @@ -42,11 +42,13 @@ def get_queryset(self) -> QuerySet[AuditLog]: serializer = AuditLogsQueryParamSerializer(data=self.request.GET) serializer.is_valid(raise_exception=True) + if (is_org_level := serializer.data.get("is_organisation_level")) is not None: + q = q & Q(organisation__isnull=not is_org_level) if project_id := serializer.data.get("project"): q = q & Q(project__id=project_id) if environment_ids := serializer.data.get("environments"): q = q & Q(environment__id__in=environment_ids) - if is_system_event := serializer.data.get("is_system_event") is not None: + if (is_system_event := serializer.data.get("is_system_event")) is not None: q = q & Q(is_system_event=is_system_event) search = serializer.data.get("search") @@ -54,7 +56,7 @@ def get_queryset(self) -> QuerySet[AuditLog]: q = q & Q(log__icontains=search) queryset = AuditLog.objects.filter(q).select_related( - "project", "environment", "author" + "organisation", "project", "environment", "author" ) return self._apply_visibility_limits(queryset) @@ -92,11 +94,17 @@ def _get_organisation(self) -> Organisation | None: class AllAuditLogViewSet(_BaseAuditLogViewSet): def _get_base_filters(self) -> Q: - return Q( + directly_related = Q( + organisation__userorganisation__user=self.request.user, + organisation__userorganisation__role=OrganisationRole.ADMIN, + organisation__userorganisation__is_active=True, + ) + indirectly_related = Q( project__organisation__userorganisation__user=self.request.user, project__organisation__userorganisation__role=OrganisationRole.ADMIN, project__organisation__userorganisation__is_active=True, ) + return directly_related | indirectly_related def _get_organisation(self) -> Organisation | None: """ @@ -120,7 +128,10 @@ class OrganisationAuditLogViewSet(_BaseAuditLogViewSet): permission_classes = [IsAuthenticated, OrganisationAuditLogPermissions] def _get_base_filters(self) -> Q: - return Q(project__organisation__id=self.kwargs["organisation_pk"]) + organisation_id = self.kwargs["organisation_pk"] + directly_related = Q(organisation__id=organisation_id) + indirectly_related = Q(project__organisation__id=organisation_id) + return directly_related | indirectly_related def _get_organisation(self) -> Organisation | None: return ( # type: ignore[no-any-return] diff --git a/api/tests/integration/audit/conftest.py b/api/tests/integration/audit/conftest.py new file mode 100644 index 000000000000..824f2d78f5fa --- /dev/null +++ b/api/tests/integration/audit/conftest.py @@ -0,0 +1,13 @@ +import pytest +from pytest_mock import MockerFixture + +from organisations.subscriptions.metadata import BaseSubscriptionMetadata + + +@pytest.fixture(autouse=True) +def _subscription_metadata(mocker: MockerFixture) -> None: + """Lift the free plan's audit log visibility limit, which hides all past logs.""" + mocker.patch( + "organisations.models.Subscription.get_subscription_metadata", + return_value=BaseSubscriptionMetadata(audit_log_visibility_days=None), + ) diff --git a/api/tests/integration/audit/test_all_audit_logs.py b/api/tests/integration/audit/test_all_audit_logs.py new file mode 100644 index 000000000000..fcdf31f8a942 --- /dev/null +++ b/api/tests/integration/audit/test_all_audit_logs.py @@ -0,0 +1,30 @@ +from rest_framework.test import APIClient + +from audit.models import AuditLog + + +def test_get_logs__mixed_direct_and_indirect_relation_to_organisation__responds_200_with_all_logs( + admin_client: APIClient, + environment: int, + organisation: int, + project: int, +) -> None: + # Given + AuditLog.objects.create( + organisation_id=organisation, + log="User `alice@example.com` joined the organisation", + ) + AuditLog.objects.create(project_id=project, log="Flag `squeeze` created") + AuditLog.objects.create(environment_id=environment, log="Flag `squeeze` enabled") + + # When + response = admin_client.get("/api/v1/audit/") + + # Then + assert response.status_code == 200 + assert [result["log"] for result in response.json()["results"]] == [ + "Flag `squeeze` enabled", + "Flag `squeeze` created", + "User `alice@example.com` joined the organisation", + "New Environment created: Test Environment", + ] diff --git a/api/tests/integration/audit/test_audit_logs.py b/api/tests/integration/audit/test_audit_logs.py index 9fcc5fa04e22..2aec7dd3f766 100644 --- a/api/tests/integration/audit/test_audit_logs.py +++ b/api/tests/integration/audit/test_audit_logs.py @@ -6,7 +6,6 @@ from django.utils import timezone from freezegun import freeze_time from pytest_django.fixtures import SettingsWrapper -from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient @@ -14,21 +13,9 @@ from core.signals import create_audit_log_from_historical_record from features.models import FeatureState from features.workflows.core.models import ChangeRequest -from organisations.subscriptions.metadata import BaseSubscriptionMetadata from users.models import FFAdminUser -@pytest.fixture(autouse=True) -def _subscription_metadata(mocker: MockerFixture) -> None: - metadata = BaseSubscriptionMetadata( - audit_log_visibility_days=None, - ) - mocker.patch( - "organisations.models.Subscription.get_subscription_metadata", - return_value=metadata, - ) - - def test_list_audit_logs__with_project_filter__makes_expected_queries( # type: ignore[no-untyped-def] admin_client, project, diff --git a/api/tests/integration/audit/test_organisation_level_audit_logs.py b/api/tests/integration/audit/test_organisation_level_audit_logs.py new file mode 100644 index 000000000000..05f666ea9a36 --- /dev/null +++ b/api/tests/integration/audit/test_organisation_level_audit_logs.py @@ -0,0 +1,356 @@ +import json +from unittest import mock + +import pytest +import responses +from pytest_django import DjangoAssertNumQueries +from rest_framework.test import APIClient + +from audit.models import AuditLog +from environments.models import Environment +from organisations.models import Organisation +from projects.models import Project +from users.models import FFAdminUser + + +@pytest.fixture +def project(organisation: int) -> int: + """Create a project outside the API, so it records no audit log.""" + return Project.objects.create( # type: ignore[no-any-return] # Removable once django-softdelete is typed + name="Orangify website", organisation_id=organisation + ).id + + +@pytest.fixture +def environment(project: int) -> int: + """Create an environment outside the API, so it records no audit log.""" + return Environment.objects.create( # type: ignore[no-any-return] # Removable once django-softdelete is typed + name="Production", project_id=project + ).id + + +def test_get_logs__logs_directly_associated_with_organisation__responds_200_with_them( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + other_organisation = Organisation.objects.create(name="Bananas Inc") + AuditLog.objects.create( + organisation_id=other_organisation.id, + log="User `bob@example.com` joined the organisation", + ) + audit_log = AuditLog.objects.create( + organisation_id=organisation, + log="User `alice@example.com` joined the organisation", + ) + + # When + response = admin_client.get(f"/api/v1/organisations/{organisation}/audit/") + + # Then + assert response.status_code == 200 + assert response.json() == { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": audit_log.id, + "created_date": mock.ANY, + "log": "User `alice@example.com` joined the organisation", + "author": None, + "organisation": {"id": organisation, "name": "Test org"}, + "environment": None, + "project": None, + "related_object_id": None, + "related_object_uuid": None, + "related_object_type": None, + "is_system_event": False, + } + ], + } + + +def test_get_logs__logs_indirectly_associated_with_organisation__responds_200_with_them( + admin_client: APIClient, + environment: int, + organisation: int, + project: int, +) -> None: + # Given + other_organisation = Organisation.objects.create(name="Bananas Inc") + other_project = Project.objects.create( + name="Winged Bananas Website", organisation=other_organisation + ) + AuditLog.objects.create(project=other_project, log="Flag `peel` created") + AuditLog.objects.create(project_id=project, log="Flag `squeeze` created") + AuditLog.objects.create(environment_id=environment, log="Flag `squeeze` enabled") + + # When + response = admin_client.get(f"/api/v1/organisations/{organisation}/audit/") + + # Then + assert response.status_code == 200 + assert [result["log"] for result in response.json()["results"]] == [ + "Flag `squeeze` enabled", + "Flag `squeeze` created", + ] + + +@pytest.mark.parametrize( + ["filter_args", "expected"], + [ + ( + {"project": "project"}, + [ + "Flag `squeeze` enabled", + "Project `Orangify website` created", + ], + ), + ( + {"environments": "environment"}, + [ + "Flag `squeeze` enabled", + ], + ), + ( + {"project": "project", "environments": "environment"}, + [ + "Flag `squeeze` enabled", + ], + ), + ], +) +def test_get_logs__mixed_logs_with_filter__responds_200_with_filtered_logs( + admin_client: APIClient, + environment: int, + expected: list[str], + filter_args: dict[str, str], + organisation: int, + project: int, + request: pytest.FixtureRequest, +) -> None: + # Given + AuditLog.objects.create( + organisation_id=organisation, + log="User `alice@example.com` joined the organisation", + ) + AuditLog.objects.create( + project_id=project, + log="Project `Orangify website` created", + ) + AuditLog.objects.create( + environment_id=environment, + log="Flag `squeeze` enabled", + ) + + # When + response = admin_client.get( + f"/api/v1/organisations/{organisation}/audit/", + {name: request.getfixturevalue(value) for name, value in filter_args.items()}, + ) + + # Then + assert response.status_code == 200 + assert [result["log"] for result in response.json()["results"]] == expected + + +@pytest.mark.parametrize( + ["is_organisation_level", "expected"], + [ + ( + True, + [ + "User `alice@example.com` joined the organisation", + ], + ), + ( + False, + [ + "Flag `squeeze` enabled", + "Project `Orangify website` created", + ], + ), + ], +) +def test_get_logs__mixed_logs_filtered_by_is_organisation_level__responds_200_with_filtered_logs( + admin_client: APIClient, + environment: int, + expected: list[str], + is_organisation_level: bool, + organisation: int, + project: int, +) -> None: + # Given + AuditLog.objects.create( + organisation_id=organisation, + log="User `alice@example.com` joined the organisation", + ) + AuditLog.objects.create( + project_id=project, + log="Project `Orangify website` created", + ) + AuditLog.objects.create( + environment_id=environment, + log="Flag `squeeze` enabled", + ) + + # When + response = admin_client.get( + f"/api/v1/organisations/{organisation}/audit/", + {"is_organisation_level": is_organisation_level}, + ) + + # Then + assert response.status_code == 200 + assert [result["log"] for result in response.json()["results"]] == expected + + +@pytest.mark.parametrize( + "filter_args", + [ + {"project": "project"}, + {"environments": "environment"}, + {"project": "project", "environments": "environment"}, + ], +) +def test_get_logs__filters_by_both_is_organisation_level_and_project_or_environment__responds_400( + admin_client: APIClient, + environment: int, + filter_args: dict[str, str], + organisation: int, + project: int, + request: pytest.FixtureRequest, +) -> None: + # Given / When + response = admin_client.get( + f"/api/v1/organisations/{organisation}/audit/", + { + "is_organisation_level": True, + **{ + name: request.getfixturevalue(value) + for name, value in filter_args.items() + }, + }, + ) + + # Then + assert response.status_code == 400 + assert response.json() == { + "non_field_errors": [ + "Organisation-level logs cannot be filtered by project or environment." + ] + } + + +def test_get_logs__mixed_logs__makes_expected_queries( + admin_client: APIClient, + admin_user: FFAdminUser, + django_assert_num_queries: DjangoAssertNumQueries, + environment: int, + organisation: int, + project: int, +) -> None: + # Given + AuditLog.objects.create( + organisation_id=organisation, + author=admin_user, + log="User `alice@example.com` joined the organisation", + ) + AuditLog.objects.create( + organisation_id=organisation, + author=admin_user, + log="User `bob@example.com` joined the organisation", + ) + AuditLog.objects.create( + project_id=project, + author=admin_user, + log="Flag `squeeze` created", + ) + AuditLog.objects.create( + project_id=project, + author=admin_user, + log="Flag `peel` created", + ) + AuditLog.objects.create( + environment_id=environment, + author=admin_user, + log="Flag `squeeze` enabled", + ) + AuditLog.objects.create( + environment_id=environment, + author=admin_user, + log="Flag `peel` enabled", + ) + + # When + with django_assert_num_queries(5): + response = admin_client.get(f"/api/v1/organisations/{organisation}/audit/") + + # Then + assert response.status_code == 200 + + +def test_get_logs__member_but_not_admin__responds_403( + non_admin_client: APIClient, + organisation: int, +) -> None: + # Given / When + response = non_admin_client.get(f"/api/v1/organisations/{organisation}/audit/") + + # Then + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_get_logs__non_member__responds_404( + organisation: int, + staff_client: APIClient, +) -> None: + # Given / When + response = staff_client.get(f"/api/v1/organisations/{organisation}/audit/") + + # Then + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} + + +@responses.activate +def test_webhook_dispatch__organisation_log__sends_webhook( + admin_client: APIClient, + organisation: int, +) -> None: + # Given + admin_client.post( + f"/api/v1/organisations/{organisation}/webhooks/", + {"url": "https://example.com/audit-hook", "enabled": True}, + format="json", + ) + responses.post("https://example.com/audit-hook", status=200) + + # When + audit_log = AuditLog.objects.create( + organisation_id=organisation, + log="User `alice@example.com` joined the organisation", + ) + + # Then + [webhook_call] = responses.calls + assert json.loads(webhook_call.request.body) == { + "event_type": "AUDIT_LOG_CREATED", + "data": { + "id": audit_log.id, + "created_date": mock.ANY, + "log": "User `alice@example.com` joined the organisation", + "author": None, + "organisation": {"id": organisation, "name": "Test org"}, + "environment": None, + "project": None, + "related_object_id": None, + "related_object_uuid": None, + "related_object_type": None, + "is_system_event": False, + }, + } diff --git a/api/tests/unit/audit/test_unit_audit_views.py b/api/tests/unit/audit/test_unit_audit_views.py index 75e613bea673..41cf6d1982f7 100644 --- a/api/tests/unit/audit/test_unit_audit_views.py +++ b/api/tests/unit/audit/test_unit_audit_views.py @@ -122,11 +122,13 @@ def test_list_audit_log__filter_by_project__returns_matching_logs( assert response.json()["results"][1]["project"]["id"] == project.id +@pytest.mark.parametrize("is_system_event", [True, False]) def test_list_audit_log__filter_by_is_system_event__returns_matching_logs( admin_client: APIClient, project: Project, environment: Environment, organisation: Organisation, + is_system_event: bool, ) -> None: # Given AuditLog.objects.create(project=project, is_system_event=True) @@ -134,15 +136,13 @@ def test_list_audit_log__filter_by_is_system_event__returns_matching_logs( project=project, environment=environment, is_system_event=False ) - url = reverse("api-v1:audit-list") - # When - response = admin_client.get(url, {"is_system_event": True}) + response = admin_client.get("/api/v1/audit/", {"is_system_event": is_system_event}) # Then assert response.json()["count"] == 1 assert response.status_code == status.HTTP_200_OK - assert response.json()["results"][0]["is_system_event"] is True + assert response.json()["results"][0]["is_system_event"] is is_system_event def test_list_audit_log__regular_user__returns_empty( diff --git a/openapi.yaml b/openapi.yaml index c2f765241d65..c7048ede8e65 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -260,6 +260,12 @@ paths: items: type: integer minimum: 0 + - name: is_organisation_level + in: query + schema: + type: + - boolean + - 'null' - name: is_system_event in: query schema: @@ -9761,6 +9767,12 @@ paths: items: type: integer minimum: 0 + - name: is_organisation_level + in: query + schema: + type: + - boolean + - 'null' - name: is_system_event in: query schema: @@ -9771,7 +9783,9 @@ paths: in: path required: true schema: - type: string + type: + - integer + - 'null' - name: page in: query description: A page number within the paginated result set. @@ -9820,7 +9834,9 @@ paths: in: path required: true schema: - type: string + type: + - integer + - 'null' responses: '200': description: '' @@ -14912,6 +14928,12 @@ paths: items: type: integer minimum: 0 + - name: is_organisation_level + in: query + schema: + type: + - boolean + - 'null' - name: is_system_event in: query schema: @@ -19215,6 +19237,8 @@ components: type: string author: $ref: '#/components/schemas/UserList' + organisation: + $ref: '#/components/schemas/OrganisationSerializerBasic' environment: $ref: '#/components/schemas/EnvironmentSerializerLight' project: @@ -19242,6 +19266,7 @@ components: - created_date - environment - log + - organisation - project AuditLogRetrieve: type: object @@ -19257,6 +19282,8 @@ components: type: string author: $ref: '#/components/schemas/UserList' + organisation: + $ref: '#/components/schemas/OrganisationSerializerBasic' environment: $ref: '#/components/schemas/EnvironmentSerializerLight' project: @@ -19308,6 +19335,7 @@ components: - created_date - environment - log + - organisation - project BaseEdgeIdentityFeatureState: type: object