diff --git a/changelog/entries/unreleased/feature/1901_introduce_workspace_level_audit_log_feature.json b/changelog/entries/unreleased/feature/1901_introduce_workspace_level_audit_log_feature.json new file mode 100644 index 000000000..9dc1a6c59 --- /dev/null +++ b/changelog/entries/unreleased/feature/1901_introduce_workspace_level_audit_log_feature.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Introduce Workspace level audit log feature", + "issue_number": 1901, + "bullet_points": [], + "created_at": "2023-08-09" +} \ No newline at end of file diff --git a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/urls.py b/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/urls.py index 0875e08c2..259b5894b 100755 --- a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/urls.py +++ b/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/urls.py @@ -1,26 +1,21 @@ from django.urls import re_path -from .views import ( - AdminAuditLogActionTypeFilterView, - AdminAuditLogUserFilterView, - AdminAuditLogView, - AdminAuditLogWorkspaceFilterView, +from baserow_enterprise.api.audit_log.views import ( AsyncAuditLogExportView, + AuditLogActionTypeFilterView, + AuditLogUserFilterView, + AuditLogView, + AuditLogWorkspaceFilterView, ) app_name = "baserow_enterprise.api.audit_log" urlpatterns = [ - re_path(r"^$", AdminAuditLogView.as_view(), name="list"), - re_path(r"users/$", AdminAuditLogUserFilterView.as_view(), name="users"), + re_path(r"^$", AuditLogView.as_view(), name="list"), + re_path(r"users/$", AuditLogUserFilterView.as_view(), name="users"), + re_path(r"workspaces/$", AuditLogWorkspaceFilterView.as_view(), name="workspaces"), re_path( - r"workspaces/$", AdminAuditLogWorkspaceFilterView.as_view(), name="workspaces" - ), - # GroupDeprecation - re_path( - r"action-types/$", - AdminAuditLogActionTypeFilterView.as_view(), - name="action_types", + r"action-types/$", AuditLogActionTypeFilterView.as_view(), name="action_types" ), re_path(r"export/$", AsyncAuditLogExportView.as_view(), name="export"), ] diff --git a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/views.py b/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/views.py deleted file mode 100755 index 2c79c6336..000000000 --- a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/views.py +++ /dev/null @@ -1,214 +0,0 @@ -from django.contrib.auth import get_user_model -from django.db import transaction -from django.utils import translation - -from baserow_premium.api.admin.views import AdminListingView -from baserow_premium.license.handler import LicenseHandler -from drf_spectacular.utils import extend_schema -from rest_framework.permissions import IsAdminUser -from rest_framework.response import Response -from rest_framework.status import HTTP_202_ACCEPTED -from rest_framework.views import APIView - -from baserow.api.decorators import ( - map_exceptions, - validate_body, - validate_query_parameters, -) -from baserow.api.jobs.errors import ERROR_MAX_JOB_COUNT_EXCEEDED -from baserow.api.jobs.serializers import JobSerializer -from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema -from baserow.core.action.registries import action_type_registry -from baserow.core.jobs.exceptions import MaxJobCountExceeded -from baserow.core.jobs.handler import JobHandler -from baserow.core.jobs.registries import job_type_registry -from baserow.core.models import Workspace -from baserow_enterprise.audit_log.job_types import AuditLogExportJobType -from baserow_enterprise.audit_log.models import AuditLogEntry -from baserow_enterprise.features import AUDIT_LOG - -from .serializers import ( - AuditLogActionTypeSerializer, - AuditLogExportJobRequestSerializer, - AuditLogExportJobResponseSerializer, - AuditLogQueryParamsSerializer, - AuditLogSerializer, - AuditLogUserSerializer, - AuditLogWorkspaceSerializer, -) - -User = get_user_model() - - -class AdminAuditLogView(AdminListingView): - permission_classes = (IsAdminUser,) - serializer_class = AuditLogSerializer - filters_field_mapping = { - "user_id": "user_id", - "workspace_id": "workspace_id", - "action_type": "action_type", - "from_timestamp": "action_timestamp__gte", - "to_timestamp": "action_timestamp__lte", - "ip_address": "ip_address", - } - sort_field_mapping = { - "user": "user_email", - "workspace": "workspace_name", - "type": "action_type", - "timestamp": "action_timestamp", - "ip_address": "ip_address", - } - default_order_by = "-action_timestamp" - - def get_queryset(self, request): - return AuditLogEntry.objects.all() - - def get_serializer(self, request, *args, **kwargs): - return super().get_serializer( - request, *args, context={"request": request}, **kwargs - ) - - @extend_schema( - tags=["Admin"], - operation_id="admin_audit_log", - description="Lists all audit log entries.", - **AdminListingView.get_extend_schema_parameters( - "audit_log_entries", serializer_class, [], sort_field_mapping - ), - ) - @validate_query_parameters(AuditLogQueryParamsSerializer) - def get(self, request, query_params): - LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide( - AUDIT_LOG, request.user - ) - with translation.override(request.user.profile.language): - return super().get(request) - - -class AdminAuditLogUserFilterView(AdminListingView): - permission_classes = (IsAdminUser,) - serializer_class = AuditLogUserSerializer - search_fields = ["email"] - default_order_by = "email" - - def get_queryset(self, request): - return User.objects.all() - - @extend_schema( - tags=["Admin"], - operation_id="admin_audit_log_users", - description="List all users that have performed an action in the audit log.", - **AdminListingView.get_extend_schema_parameters( - "users", serializer_class, search_fields, {} - ), - ) - def get(self, request): - LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide( - AUDIT_LOG, request.user - ) - return super().get(request) - - -class AdminAuditLogWorkspaceFilterView(AdminListingView): - permission_classes = (IsAdminUser,) - serializer_class = AuditLogWorkspaceSerializer - search_fields = ["name"] - default_order_by = "name" - - def get_queryset(self, request): - return Workspace.objects.filter(template__isnull=True) - - @extend_schema( - tags=["Admin"], - operation_id="admin_audit_log_workspaces", - description="List all distinct workspace names related to an audit log entry.", - **AdminListingView.get_extend_schema_parameters( - "workspaces", serializer_class, search_fields, {} - ), - ) - def get(self, request): - LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide( - AUDIT_LOG, request.user - ) - return super().get(request) - - -class AdminAuditLogActionTypeFilterView(APIView): - permission_classes = (IsAdminUser,) - serializer_class = AuditLogActionTypeSerializer - - def filter_action_types(self, action_types, search): - search_lower = search.lower() - return [ - action_type - for action_type in action_types - if search_lower in action_type["value"].lower() - ] - - @extend_schema( - tags=["Admin"], - operation_id="admin_audit_log_types", - description="List all distinct action types related to an audit log entry.", - ) - def get(self, request): - LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide( - AUDIT_LOG, request.user - ) - - # Since action's type is translated at runtime and there aren't that - # many, we can fetch them all and filter them in memory to match the - # search query on the translated value. - with translation.override(request.user.profile.language): - search = request.GET.get("search") - - action_types = AuditLogActionTypeSerializer( - action_type_registry.get_all(), many=True - ).data - - if search: - action_types = self.filter_action_types(action_types, search) - - return Response( - { - "count": len(action_types), - "next": None, - "previous": None, - "results": sorted(action_types, key=lambda x: x["value"]), - } - ) - - -class AsyncAuditLogExportView(APIView): - permission_classes = (IsAdminUser,) - - @extend_schema( - parameters=[CLIENT_SESSION_ID_SCHEMA_PARAMETER], - tags=["Audit log export"], - operation_id="export_audit_log", - description=("Creates a job to export the filtered audit log to a CSV file."), - request=AuditLogExportJobRequestSerializer, - responses={ - 202: AuditLogExportJobResponseSerializer, - 400: get_error_schema( - ["ERROR_REQUEST_BODY_VALIDATION", "ERROR_MAX_JOB_COUNT_EXCEEDED"] - ), - }, - ) - @transaction.atomic - @map_exceptions({MaxJobCountExceeded: ERROR_MAX_JOB_COUNT_EXCEEDED}) - @validate_body(AuditLogExportJobRequestSerializer) - def post(self, request, data): - """Creates a job to export the filtered audit log entries to a CSV file.""" - - LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide( - AUDIT_LOG, request.user - ) - - csv_export_job = JobHandler().create_and_start_job( - request.user, AuditLogExportJobType.type, **data - ) - - serializer = job_type_registry.get_serializer( - csv_export_job, JobSerializer, context={"request": request} - ) - return Response(serializer.data, status=HTTP_202_ACCEPTED) diff --git a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/__init__.py b/enterprise/backend/src/baserow_enterprise/api/audit_log/__init__.py similarity index 100% rename from enterprise/backend/src/baserow_enterprise/api/admin/audit_log/__init__.py rename to enterprise/backend/src/baserow_enterprise/api/audit_log/__init__.py diff --git a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/serializers.py b/enterprise/backend/src/baserow_enterprise/api/audit_log/serializers.py old mode 100755 new mode 100644 similarity index 73% rename from enterprise/backend/src/baserow_enterprise/api/admin/audit_log/serializers.py rename to enterprise/backend/src/baserow_enterprise/api/audit_log/serializers.py index 6fdd413c2..b1e546d69 --- a/enterprise/backend/src/baserow_enterprise/api/admin/audit_log/serializers.py +++ b/enterprise/backend/src/baserow_enterprise/api/audit_log/serializers.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.utils import translation from django.utils.functional import lazy from drf_spectacular.types import OpenApiTypes @@ -26,6 +27,25 @@ def render_action_type(action_type): return action_type_registry.get(action_type).get_short_description() +class AuditLogQueryParamsSerializer(serializers.Serializer): + page = serializers.IntegerField(required=False, default=1) + search = serializers.CharField(required=False, default=None) + sorts = serializers.CharField(required=False, default=None) + user_id = serializers.IntegerField(min_value=1, required=False, default=None) + workspace_id = serializers.IntegerField(min_value=1, required=False, default=None) + action_type = serializers.ChoiceField( + choices=lazy(action_type_registry.get_types, list)(), + default=None, + required=False, + ) + from_timestamp = serializers.DateTimeField(required=False, default=None) + to_timestamp = serializers.DateTimeField(required=False, default=None) + + +class AuditLogWorkspaceFilterQueryParamsSerializer(serializers.Serializer): + workspace_id = serializers.IntegerField(min_value=1, required=False, default=None) + + class AuditLogSerializer(serializers.ModelSerializer): user = serializers.SerializerMethodField() group = serializers.SerializerMethodField() # GroupDeprecation @@ -34,14 +54,6 @@ class AuditLogSerializer(serializers.ModelSerializer): description = serializers.SerializerMethodField() timestamp = serializers.DateTimeField(source="action_timestamp") - @extend_schema_field(OpenApiTypes.STR) - def get_group(self, instance): # GroupDeprecation - return self.get_workspace(instance) - - @extend_schema_field(OpenApiTypes.STR) - def get_workspace(self, instance): - return render_workspace(instance.workspace_id, instance.workspace_name) - @extend_schema_field(OpenApiTypes.STR) def get_user(self, instance): return render_user(instance.user_id, instance.user_email) @@ -54,6 +66,14 @@ class AuditLogSerializer(serializers.ModelSerializer): def get_description(self, instance): return instance.description + @extend_schema_field(OpenApiTypes.STR) + def get_group(self, instance): # GroupDeprecation + return self.get_workspace(instance) + + @extend_schema_field(OpenApiTypes.STR) + def get_workspace(self, instance): + return render_workspace(instance.workspace_id, instance.workspace_name) + class Meta: model = AuditLogEntry fields = ( @@ -98,6 +118,42 @@ class AuditLogActionTypeSerializer(serializers.Serializer): return render_action_type(instance.type) +def serialize_filtered_action_types(user, search=None, exclude_types=None): + exclude_types = exclude_types or [] + + def filter_action_types(action_types, search): + search_lower = search.lower() + return [ + action_type + for action_type in action_types + if search_lower in action_type["value"].lower() + ] + + # Since action's type is translated at runtime and there aren't that + # many, we can fetch them all and filter them in memory to match the + # search query on the translated value. + with translation.override(user.profile.language): + filtered_action_types = [ + action_type + for action_type in action_type_registry.get_all() + if action_type.type not in exclude_types + ] + + action_types = AuditLogActionTypeSerializer( + filtered_action_types, many=True + ).data + + if search: + action_types = filter_action_types(action_types, search) + + return { + "count": len(action_types), + "next": None, + "previous": None, + "results": sorted(action_types, key=lambda x: x["value"]), + } + + AuditLogExportJobRequestSerializer = job_type_registry.get( AuditLogExportJobType.type ).get_serializer_class( @@ -112,18 +168,3 @@ AuditLogExportJobResponseSerializer = job_type_registry.get( base_class=serializers.Serializer, meta_ref_name="SingleAuditLogExportJobResponseSerializer", ) - - -class AuditLogQueryParamsSerializer(serializers.Serializer): - page = serializers.IntegerField(required=False, default=1) - search = serializers.CharField(required=False, default=None) - sorts = serializers.CharField(required=False, default=None) - user_id = serializers.IntegerField(min_value=0, required=False, default=None) - workspace_id = serializers.IntegerField(min_value=0, required=False, default=None) - action_type = serializers.ChoiceField( - choices=lazy(action_type_registry.get_types, list)(), - default=None, - required=False, - ) - from_timestamp = serializers.DateTimeField(required=False, default=None) - to_timestamp = serializers.DateTimeField(required=False, default=None) diff --git a/enterprise/backend/src/baserow_enterprise/api/audit_log/urls.py b/enterprise/backend/src/baserow_enterprise/api/audit_log/urls.py new file mode 100755 index 000000000..e631b2251 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/api/audit_log/urls.py @@ -0,0 +1,25 @@ +from django.urls import re_path + +from .views import ( + AsyncAuditLogExportView, + AuditLogActionTypeFilterView, + AuditLogUserFilterView, + AuditLogView, + AuditLogWorkspaceFilterView, +) + +app_name = "baserow_enterprise.api.audit_log" + +urlpatterns = [ + re_path(r"^$", AuditLogView.as_view(), name="list"), + re_path(r"users/$", AuditLogUserFilterView.as_view(), name="users"), + re_path(r"workspaces/$", AuditLogWorkspaceFilterView.as_view(), name="workspaces"), + re_path( + r"action-types/$", AuditLogActionTypeFilterView.as_view(), name="action_types" + ), + re_path( + r"export/$", + AsyncAuditLogExportView.as_view(), + name="async_export", + ), +] diff --git a/enterprise/backend/src/baserow_enterprise/api/audit_log/views.py b/enterprise/backend/src/baserow_enterprise/api/audit_log/views.py new file mode 100755 index 000000000..c4578fcee --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/api/audit_log/views.py @@ -0,0 +1,345 @@ +from typing import Optional + +from django.contrib.auth.models import AbstractBaseUser +from django.db import transaction +from django.utils import translation + +from baserow_premium.api.admin.views import APIListingView +from baserow_premium.license.handler import LicenseHandler +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.response import Response +from rest_framework.status import HTTP_202_ACCEPTED +from rest_framework.views import APIView + +from baserow.api.decorators import ( + map_exceptions, + validate_body, + validate_query_parameters, +) +from baserow.api.errors import ERROR_GROUP_DOES_NOT_EXIST +from baserow.api.jobs.errors import ERROR_MAX_JOB_COUNT_EXCEEDED +from baserow.api.jobs.serializers import JobSerializer +from baserow.api.schemas import CLIENT_SESSION_ID_SCHEMA_PARAMETER, get_error_schema +from baserow.core.actions import DeleteWorkspaceActionType, OrderWorkspacesActionType +from baserow.core.exceptions import WorkspaceDoesNotExist +from baserow.core.handler import CoreHandler +from baserow.core.jobs.exceptions import MaxJobCountExceeded +from baserow.core.jobs.handler import JobHandler +from baserow.core.jobs.registries import job_type_registry +from baserow.core.models import User, Workspace +from baserow_enterprise.audit_log.job_types import AuditLogExportJobType +from baserow_enterprise.audit_log.models import AuditLogEntry +from baserow_enterprise.audit_log.operations import ( + ListWorkspaceAuditLogEntriesOperationType, +) +from baserow_enterprise.features import AUDIT_LOG + +from .serializers import ( + AuditLogActionTypeSerializer, + AuditLogExportJobRequestSerializer, + AuditLogExportJobResponseSerializer, + AuditLogQueryParamsSerializer, + AuditLogSerializer, + AuditLogUserSerializer, + AuditLogWorkspaceFilterQueryParamsSerializer, + AuditLogWorkspaceSerializer, + serialize_filtered_action_types, +) + + +def check_for_license_and_permissions_or_raise( + user: AbstractBaseUser, workspace_id: Optional[int] = None +): + """ + Check if the user has the feature enabled and has the correct permissions to list + audit log entries. If not, an exception is raised. + """ + + if user.is_staff: + LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide(AUDIT_LOG, user) + return True + elif workspace_id is not None: + workspace = CoreHandler().get_workspace(workspace_id) + LicenseHandler.raise_if_user_doesnt_have_feature(AUDIT_LOG, user, workspace) + CoreHandler().check_permissions( + user, + ListWorkspaceAuditLogEntriesOperationType.type, + workspace=workspace, + context=workspace, + ) + else: + raise PermissionDenied() + + +class AuditLogView(APIListingView): + permission_classes = (IsAuthenticated,) + serializer_class = AuditLogSerializer + filters_field_mapping = { + "user_id": "user_id", + "workspace_id": "workspace_id", + "action_type": "action_type", + "from_timestamp": "action_timestamp__gte", + "to_timestamp": "action_timestamp__lte", + "ip_address": "ip_address", + } + sort_field_mapping = { + "user": "user_email", + "workspace": "workspace_name", + "type": "action_type", + "timestamp": "action_timestamp", + "ip_address": "ip_address", + } + default_order_by = "-action_timestamp" + + def get_queryset(self, request): + return AuditLogEntry.objects.all() + + def get_serializer(self, request, *args, **kwargs): + return super().get_serializer( + request, *args, context={"request": request}, **kwargs + ) + + @extend_schema( + tags=["Audit log"], + operation_id="audit_log_list", + description=( + "Lists all audit log entries for the given workspace id." + "\n\nThis is a **enterprise** feature." + ), + **APIListingView.get_extend_schema_parameters( + "audit log entries", + serializer_class, + [], + sort_field_mapping, + extra_parameters=[ + OpenApiParameter( + name="user_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Filter the audit log entries by user id.", + ), + OpenApiParameter( + name="workspace_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description=( + "Filter the audit log entries by workspace id. " + "This filter works only for the admin audit log." + ), + ), + OpenApiParameter( + name="action_type", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Filter the audit log entries by action type.", + ), + OpenApiParameter( + name="from_timestamp", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="The ISO timestamp to filter the audit log entries from.", + ), + OpenApiParameter( + name="to_timestamp", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="The ISO timestamp to filter the audit log entries to.", + ), + ], + ), + ) + @map_exceptions( + { + WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST, + } + ) + @validate_query_parameters(AuditLogQueryParamsSerializer) + def get(self, request, query_params): + workspace_id = query_params.get("workspace_id", None) + check_for_license_and_permissions_or_raise(request.user, workspace_id) + + with translation.override(request.user.profile.language): + return super().get(request) + + +class AuditLogActionTypeFilterView(APIView): + permission_classes = (IsAuthenticated,) + serializer_class = AuditLogActionTypeSerializer + + @extend_schema( + tags=["Audit log"], + operation_id="audit_log_action_types", + description=( + "List all distinct action types related to an audit log entry." + "\n\nThis is a **enterprise** feature." + ), + parameters=[ + OpenApiParameter( + name="search", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="If provided only action_types with name " + "that match the query will be returned.", + ), + OpenApiParameter( + name="workspace_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description=("Return action types related to the workspace."), + ), + ], + responses={ + 200: serializer_class(many=True), + 400: get_error_schema( + [ + "ERROR_PAGE_SIZE_LIMIT", + "ERROR_INVALID_PAGE", + "ERROR_INVALID_SORT_DIRECTION", + "ERROR_INVALID_SORT_ATTRIBUTE", + ] + ), + 401: None, + }, + ) + @map_exceptions( + { + WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST, + } + ) + @validate_query_parameters(AuditLogWorkspaceFilterQueryParamsSerializer) + def get(self, request, query_params): + workspace_id = query_params.get("workspace_id", None) + + check_for_license_and_permissions_or_raise( + request.user, workspace_id=workspace_id + ) + search = request.GET.get("search", None) + + exclude_types = [] + if workspace_id is not None: + exclude_types += [ + DeleteWorkspaceActionType.type, + OrderWorkspacesActionType.type, + ] + + return Response( + serialize_filtered_action_types(request.user, search, exclude_types) + ) + + +class AuditLogUserFilterView(APIListingView): + permission_classes = (IsAuthenticated,) + serializer_class = AuditLogUserSerializer + filters_field_mapping = {"workspace_id": "workspaceuser__workspace_id"} + search_fields = ["email"] + default_order_by = "email" + + def get_queryset(self, request): + return User.objects.all() + + @extend_schema( + tags=["Audit log"], + operation_id="audit_log_users", + description=( + "List all users that have performed an action in the audit log." + "\n\nThis is a **enterprise** feature." + ), + **APIListingView.get_extend_schema_parameters( + "users", + serializer_class, + search_fields, + {}, + extra_parameters=[ + OpenApiParameter( + name="workspace_id", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description=("Return users belonging to the given workspace_id."), + ), + ], + ), + ) + @map_exceptions( + { + WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST, + } + ) + @validate_query_parameters(AuditLogWorkspaceFilterQueryParamsSerializer) + def get(self, request, query_params): + workspace_id = query_params.get("workspace_id", None) + check_for_license_and_permissions_or_raise(request.user, workspace_id) + return super().get(request) + + +class AuditLogWorkspaceFilterView(APIListingView): + permission_classes = (IsAdminUser,) + serializer_class = AuditLogWorkspaceSerializer + search_fields = ["name"] + default_order_by = "name" + + def get_queryset(self, request): + return Workspace.objects.filter(template__isnull=True) + + @extend_schema( + tags=["Audit log"], + operation_id="audit_log_workspaces", + description=( + "List all distinct workspace names related to an audit log entry." + "\n\nThis is a **enterprise** feature." + ), + **APIListingView.get_extend_schema_parameters( + "workspaces", serializer_class, search_fields, {} + ), + ) + def get(self, request): + LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide( + AUDIT_LOG, request.user + ) + return super().get(request) + + +class AsyncAuditLogExportView(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + parameters=[CLIENT_SESSION_ID_SCHEMA_PARAMETER], + tags=["Audit log"], + operation_id="async_audit_log_export", + description=( + "Creates a job to export the filtered audit log to a CSV file." + "\n\nThis is a **enterprise** feature." + ), + request=AuditLogExportJobRequestSerializer, + responses={ + 202: AuditLogExportJobResponseSerializer, + 400: get_error_schema( + ["ERROR_REQUEST_BODY_VALIDATION", "ERROR_MAX_JOB_COUNT_EXCEEDED"] + ), + 404: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]), + }, + ) + @transaction.atomic + @map_exceptions( + { + MaxJobCountExceeded: ERROR_MAX_JOB_COUNT_EXCEEDED, + WorkspaceDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST, + } + ) + @validate_body(AuditLogExportJobRequestSerializer, return_validated=True) + def post(self, request, data): + """Creates a job to export the filtered audit log entries to a CSV file.""" + + workspace_id = data.get("filter_workspace_id", None) + check_for_license_and_permissions_or_raise(request.user, workspace_id) + + csv_export_job = JobHandler().create_and_start_job( + request.user, AuditLogExportJobType.type, **data + ) + + serializer = job_type_registry.get_serializer( + csv_export_job, JobSerializer, context={"request": request} + ) + return Response(serializer.data, status=HTTP_202_ACCEPTED) diff --git a/enterprise/backend/src/baserow_enterprise/api/urls.py b/enterprise/backend/src/baserow_enterprise/api/urls.py index 13d43866c..e84e96bd2 100644 --- a/enterprise/backend/src/baserow_enterprise/api/urls.py +++ b/enterprise/backend/src/baserow_enterprise/api/urls.py @@ -1,6 +1,7 @@ from django.urls import include, path from .admin import urls as admin_urls +from .audit_log import urls as audit_log_urls from .role import urls as role_urls from .sso import urls as sso_urls from .teams import urls as teams_urls @@ -12,4 +13,5 @@ urlpatterns = [ path("role/", include(role_urls, namespace="role")), path("admin/", include(admin_urls, namespace="admin")), path("sso/", include(sso_urls, namespace="sso")), + path("audit-log/", include(audit_log_urls, namespace="audit_log")), ] diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index 53429f5f1..cedb22127 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -10,6 +10,9 @@ class BaserowEnterpriseConfig(AppConfig): def ready(self): from baserow.core.jobs.registries import job_type_registry from baserow_enterprise.audit_log.job_types import AuditLogExportJobType + from baserow_enterprise.audit_log.operations import ( + ListWorkspaceAuditLogEntriesOperationType, + ) job_type_registry.register(AuditLogExportJobType()) @@ -101,6 +104,7 @@ class BaserowEnterpriseConfig(AppConfig): operation_type_registry.register(UpdateRoleApplicationOperationType()) operation_type_registry.register(ReadRoleTableOperationType()) operation_type_registry.register(UpdateRoleTableOperationType()) + operation_type_registry.register(ListWorkspaceAuditLogEntriesOperationType()) from baserow.core.registries import subject_type_registry diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/handler.py b/enterprise/backend/src/baserow_enterprise/audit_log/handler.py index bfc4aa7ca..0d5553e58 100755 --- a/enterprise/backend/src/baserow_enterprise/audit_log/handler.py +++ b/enterprise/backend/src/baserow_enterprise/audit_log/handler.py @@ -3,13 +3,10 @@ from typing import Any, Dict, Optional, Type from django.contrib.auth.models import AbstractUser -from baserow_premium.license.handler import LicenseHandler - from baserow.api.sessions import get_user_remote_addr_ip from baserow.core.action.registries import ActionType from baserow.core.action.signals import ActionCommandType from baserow.core.models import Workspace -from baserow_enterprise.features import AUDIT_LOG from .models import AuditLogEntry @@ -44,12 +41,8 @@ class AuditLogHandler: is sent so it can be used to identify other resources created at the same time (i.e. row_history entries). :param workspace: The workspace that the action was performed on. - :raises FeaturesNotAvailableError: When the AUDIT_LOG feature is not - available. """ - LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide(AUDIT_LOG, user) - workspace_id, workspace_name = None, None if workspace is not None: workspace_id = workspace.id diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/job_types.py b/enterprise/backend/src/baserow_enterprise/audit_log/job_types.py index 2c8dfb220..0f7ed8b05 100755 --- a/enterprise/backend/src/baserow_enterprise/audit_log/job_types.py +++ b/enterprise/backend/src/baserow_enterprise/audit_log/job_types.py @@ -30,6 +30,61 @@ from baserow_enterprise.features import AUDIT_LOG from .models import AuditLogEntry, AuditLogExportJob +AUDIT_LOG_CSV_COLUMN_NAMES = OrderedDict( + { + "user_email": { + "field": "user_email", + "descr": _("User Email"), + }, + "user_id": { + "field": "user_id", + "descr": _("User ID"), + }, + "workspace_name": { + "field": "workspace_name", + "descr": _("Group Name"), + }, + "workspace_id": { + "field": "workspace_id", + "descr": _("Group ID"), + }, + "type": { + "field": "type", + "descr": _("Action Type"), + }, + "description": { + "field": "description", + "descr": _("Description"), + }, + "timestamp": { + "field": "action_timestamp", + "descr": _("Timestamp"), + }, + "ip_address": { + "field": "ip_address", + "descr": _("IP Address"), + }, + } +) + + +class CommaSeparatedCsvColumnsField(serializers.CharField): + def validate_values(self, value): + items = value.split(",") + + if len(set(items)) != len(items): + raise serializers.ValidationError("Duplicate items are not allowed.") + + if len(items) > 0: + for item in items: + if item not in AUDIT_LOG_CSV_COLUMN_NAMES.keys(): + raise serializers.ValidationError(f"{item} is not a valid choice.") + + if len(items) == len(self.child.choices): + raise serializers.ValidationError("At least one column must be included.") + + return value + class AuditLogExportJobType(JobType): type = "audit_log_export" @@ -46,24 +101,16 @@ class AuditLogExportJobType(JobType): "filter_action_type", "filter_from_timestamp", "filter_to_timestamp", + "exclude_columns", ] serializer_field_names = [ - "csv_column_separator", - "csv_first_row_header", - "export_charset", - "filter_user_id", - "filter_workspace_id", - "filter_action_type", - "filter_from_timestamp", - "filter_to_timestamp", + *request_serializer_field_names, "created_on", "exported_file_name", "url", ] - serializer_field_overrides = { - # Map to the python encoding aliases at the same time by using a - # DisplayChoiceField + base_serializer_field_overrides = { "export_charset": DisplayChoiceField( choices=SUPPORTED_EXPORT_CHARSETS, default="utf-8", @@ -106,10 +153,32 @@ class AuditLogExportJobType(JobType): required=False, help_text="Optional: The end date to filter the audit log by.", ), + "exclude_columns": CommaSeparatedCsvColumnsField( + required=False, + help_text=( + "Optional: A comma separated list of column names to exclude from the export. " + f"Available options are `{', '.join(AUDIT_LOG_CSV_COLUMN_NAMES.keys())}`." + ), + ), + } + request_serializer_field_overrides = { + **base_serializer_field_overrides, + } + serializer_field_overrides = { + # Map to the python encoding aliases at the same time by using a + # DisplayChoiceField + **base_serializer_field_overrides, "created_on": serializers.DateTimeField( read_only=True, help_text="The date and time when the export job was created.", ), + "exported_file_name": serializers.CharField( + read_only=True, + help_text="The name of the file that was created by the export job.", + ), + "url": serializers.SerializerMethodField( + help_text="The URL to download the exported file.", + ), } def before_delete(self, job): @@ -135,18 +204,12 @@ class AuditLogExportJobType(JobType): if job.export_charset == "utf-8": file.write(b"\xef\xbb\xbf") - field_header_mapping = OrderedDict( - { - "user_email": _("User Email"), - "user_id": _("User ID"), - "workspace_name": _("Group Name"), # GroupDeprecation - "workspace_id": _("Group ID"), - "type": _("Action Type"), - "description": _("Description"), - "action_timestamp": _("Timestamp"), - "ip_address": _("IP Address"), - } - ) + exclude_columns = job.exclude_columns.split(",") if job.exclude_columns else [] + field_header_mapping = { + k: v["descr"] + for (k, v) in AUDIT_LOG_CSV_COLUMN_NAMES.items() + if k not in exclude_columns + } writer = csv.writer( file, @@ -158,7 +221,11 @@ class AuditLogExportJobType(JobType): if job.csv_first_row_header: writer.writerow(field_header_mapping.values()) - fields = field_header_mapping.keys() + fields = [ + v["field"] + for (k, v) in AUDIT_LOG_CSV_COLUMN_NAMES.items() + if k not in exclude_columns + ] paginator = Paginator(queryset.all(), 2000) export_progress = ChildProgressBuilder.build( progress.create_child_builder(represents_progress=progress.total), diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/models.py b/enterprise/backend/src/baserow_enterprise/audit_log/models.py index a675c6897..289ead9a9 100755 --- a/enterprise/backend/src/baserow_enterprise/audit_log/models.py +++ b/enterprise/backend/src/baserow_enterprise/audit_log/models.py @@ -32,10 +32,10 @@ class AuditLogEntry(CreatedAndUpdatedOnMixin, models.Model): REDO = ActionCommandType.UNDO.name, _("REDONE") user_id = models.PositiveIntegerField(null=True) - user_email = models.CharField(max_length=150, null=True, blank=True) + user_email = models.EmailField(null=True, blank=True) workspace_id = models.PositiveIntegerField(null=True) - workspace_name = models.CharField(max_length=160, null=True, blank=True) + workspace_name = models.CharField(max_length=165, null=True, blank=True) action_uuid = models.CharField(max_length=36, null=True) action_type = models.TextField() @@ -50,8 +50,8 @@ class AuditLogEntry(CreatedAndUpdatedOnMixin, models.Model): # we don't want break the audit log in case an action is removed or changed. # Storing the original description and type in the database we'll always be # able to fallback to them and show the original string in case. NOTE: if - # the _('$original_description') has been removed from the codebase, the - # entry won't be translated anymore. + # also the _('$original_description') has been removed from the codebase, + # the entry won't be translated anymore. original_action_short_descr = models.TextField(null=True, blank=True) original_action_long_descr = models.TextField(null=True, blank=True) original_action_context_descr = models.TextField(null=True, blank=True) @@ -147,3 +147,8 @@ class AuditLogExportJob(Job): null=True, help_text="The CSV file containing the filtered audit log entries.", ) + exclude_columns = models.CharField( + max_length=255, + null=True, + help_text="A comma separated list of column names to exclude from the export.", + ) diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/operations.py b/enterprise/backend/src/baserow_enterprise/audit_log/operations.py new file mode 100644 index 000000000..aaa8bea12 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/audit_log/operations.py @@ -0,0 +1,5 @@ +from baserow.core.operations import WorkspaceCoreOperationType + + +class ListWorkspaceAuditLogEntriesOperationType(WorkspaceCoreOperationType): + type = "workspace.list_audit_log_entries" diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/signals.py b/enterprise/backend/src/baserow_enterprise/audit_log/signals.py index 514935170..65a3059bf 100755 --- a/enterprise/backend/src/baserow_enterprise/audit_log/signals.py +++ b/enterprise/backend/src/baserow_enterprise/audit_log/signals.py @@ -4,8 +4,6 @@ from typing import Any, Dict, Optional, Type from django.contrib.auth.models import AbstractUser from django.dispatch import receiver -from baserow_premium.license.exceptions import FeaturesNotAvailableError - from baserow.core.action.registries import ActionType from baserow.core.action.signals import ActionCommandType, action_done from baserow.core.models import Workspace @@ -25,16 +23,13 @@ def log_action( workspace: Optional[Workspace] = None, **kwargs ): - try: - AuditLogHandler.log_action( - user, - action_type, - action_params, - action_timestamp, - action_command_type, - action_uuid=action_uuid, - workspace=workspace, - **kwargs - ) - except FeaturesNotAvailableError: - pass + AuditLogHandler.log_action( + user, + action_type, + action_params, + action_timestamp, + action_command_type, + action_uuid=action_uuid, + workspace=workspace, + **kwargs + ) diff --git a/enterprise/backend/src/baserow_enterprise/audit_log/utils.py b/enterprise/backend/src/baserow_enterprise/audit_log/utils.py new file mode 100644 index 000000000..e69de29bb diff --git a/enterprise/backend/src/baserow_enterprise/migrations/0022_workspace_audit_log.py b/enterprise/backend/src/baserow_enterprise/migrations/0022_workspace_audit_log.py new file mode 100644 index 000000000..a29320edd --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/migrations/0022_workspace_audit_log.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.20 on 2023-08-09 10:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("baserow_enterprise", "0021_auditlogentry_action_uuid"), + ] + + operations = [ + migrations.AddField( + model_name="auditlogexportjob", + name="exclude_columns", + field=models.CharField( + help_text="A comma separated list of column names to exclude from the export.", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="auditlogentry", + name="user_email", + field=models.EmailField(blank=True, max_length=254, null=True), + ), + migrations.AlterField( + model_name="auditlogentry", + name="workspace_name", + field=models.CharField(blank=True, max_length=165, null=True), + ), + ] diff --git a/enterprise/backend/src/baserow_enterprise/role/default_roles.py b/enterprise/backend/src/baserow_enterprise/role/default_roles.py index b74e95e88..3cf696235 100755 --- a/enterprise/backend/src/baserow_enterprise/role/default_roles.py +++ b/enterprise/backend/src/baserow_enterprise/role/default_roles.py @@ -179,6 +179,9 @@ from baserow.core.trash.operations import ( ReadApplicationTrashOperationType, ReadWorkspaceTrashOperationType, ) +from baserow_enterprise.audit_log.operations import ( + ListWorkspaceAuditLogEntriesOperationType, +) from baserow_enterprise.role.constants import ( ADMIN_ROLE_UID, BUILDER_ROLE_UID, @@ -411,5 +414,6 @@ default_roles[ADMIN_ROLE_UID].extend( ListSnapshotsApplicationOperationType, DeleteApplicationSnapshotOperationType, RestoreDomainOperationType, + ListWorkspaceAuditLogEntriesOperationType, ] ) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/admin/audit_log/test_audit_log_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/audit_log/test_audit_log_admin_views.py similarity index 89% rename from enterprise/backend/tests/baserow_enterprise_tests/api/admin/audit_log/test_audit_log_views.py rename to enterprise/backend/tests/baserow_enterprise_tests/api/audit_log/test_audit_log_admin_views.py index fa6dfd13d..99cd32bb2 100755 --- a/enterprise/backend/tests/baserow_enterprise_tests/api/admin/audit_log/test_audit_log_views.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/audit_log/test_audit_log_admin_views.py @@ -28,15 +28,23 @@ from baserow_enterprise.audit_log.models import AuditLogEntry @pytest.mark.django_db -@pytest.mark.parametrize("url_name", ["users", "workspaces", "action_types", "list"]) +@pytest.mark.parametrize( + "method,url_name", + [ + ("get", "users"), + ("get", "action_types"), + ("get", "list"), + ("post", "async_export"), + ], +) @override_settings(DEBUG=True) -def test_admins_can_not_access_audit_log_endpoints_without_an_enterprise_license( - api_client, enterprise_data_fixture, url_name +def test_admins_cannot_access_audit_log_endpoints_without_an_enterprise_license( + api_client, enterprise_data_fixture, method, url_name ): - user, token = enterprise_data_fixture.create_user_and_token(is_staff=True) + _, token = enterprise_data_fixture.create_user_and_token(is_staff=True) - response = api_client.get( - reverse(f"api:enterprise:admin:audit_log:{url_name}"), + response = getattr(api_client, method)( + reverse(f"api:enterprise:audit_log:{url_name}"), format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -46,15 +54,15 @@ def test_admins_can_not_access_audit_log_endpoints_without_an_enterprise_license @pytest.mark.django_db @pytest.mark.parametrize("url_name", ["users", "workspaces", "action_types", "list"]) @override_settings(DEBUG=True) -def test_non_admins_can_not_access_audit_log_endpoints( +def test_non_admins_cannot_access_audit_log_endpoints( api_client, enterprise_data_fixture, url_name ): enterprise_data_fixture.enable_enterprise() - user, token = enterprise_data_fixture.create_user_and_token() + _, token = enterprise_data_fixture.create_user_and_token() response = api_client.get( - reverse(f"api:enterprise:admin:audit_log:{url_name}"), + reverse(f"api:enterprise:audit_log:{url_name}"), format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -63,30 +71,13 @@ def test_non_admins_can_not_access_audit_log_endpoints( @pytest.mark.django_db @override_settings(DEBUG=True) -def test_admins_can_not_export_audit_log_to_csv_without_an_enterprise_license( - api_client, enterprise_data_fixture -): - user, token = enterprise_data_fixture.create_user_and_token(is_staff=True) - - response = api_client.post( - reverse(f"api:enterprise:admin:audit_log:export"), - format="json", - HTTP_AUTHORIZATION=f"JWT {token}", - ) - assert response.status_code == HTTP_402_PAYMENT_REQUIRED - - -@pytest.mark.django_db -@override_settings(DEBUG=True) -def test_non_admins_can_not_export_audit_log_to_csv( - api_client, enterprise_data_fixture -): +def test_non_admins_cannot_export_audit_log_to_csv(api_client, enterprise_data_fixture): enterprise_data_fixture.enable_enterprise() user, token = enterprise_data_fixture.create_user_and_token() response = api_client.post( - reverse(f"api:enterprise:admin:audit_log:export"), + reverse("api:enterprise:audit_log:async_export"), format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -108,7 +99,7 @@ def test_audit_log_user_filter_returns_users_correctly( # no search query should return all users response = api_client.get( - reverse("api:enterprise:admin:audit_log:users"), + reverse("api:enterprise:audit_log:users"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -125,7 +116,7 @@ def test_audit_log_user_filter_returns_users_correctly( # searching by email should return only the correct user response = api_client.get( - reverse("api:enterprise:admin:audit_log:users") + "?search=admin", + reverse("api:enterprise:audit_log:users") + "?search=admin", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -156,7 +147,7 @@ def test_audit_log_workspace_filter_returns_workspaces_correctly( # no search query should return all workspaces response = api_client.get( - reverse("api:enterprise:admin:audit_log:workspaces"), + reverse("api:enterprise:audit_log:workspaces"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -173,7 +164,7 @@ def test_audit_log_workspace_filter_returns_workspaces_correctly( # searching by name should return only the correct workspace response = api_client.get( - reverse("api:enterprise:admin:audit_log:workspaces") + "?search=1", + reverse("api:enterprise:audit_log:workspaces") + "?search=1", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -200,7 +191,7 @@ def test_audit_log_action_type_filter_returns_action_types_correctly( # no search query should return all the available action types`` response = api_client.get( - reverse("api:enterprise:admin:audit_log:action_types"), + reverse("api:enterprise:audit_log:action_types"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -220,7 +211,7 @@ def test_audit_log_action_type_filter_returns_action_types_correctly( # searching by name should return only the correct action_type response = api_client.get( - reverse("api:enterprise:admin:audit_log:action_types") + reverse("api:enterprise:audit_log:action_types") + f"?search=create+application", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", @@ -246,7 +237,7 @@ def test_audit_log_action_types_are_translated_in_the_admin_language( with patch("django.utils.translation.override") as mock_override: api_client.get( - reverse("api:enterprise:admin:audit_log:action_types"), + reverse("api:enterprise:audit_log:action_types"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -254,10 +245,7 @@ def test_audit_log_action_types_are_translated_in_the_admin_language( # the search works in the user language response = api_client.get( - ( - reverse("api:enterprise:admin:audit_log:action_types") - + f"?search=crea+progetto" - ), + (reverse("api:enterprise:audit_log:action_types") + f"?search=crea+progetto"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -272,7 +260,7 @@ def test_audit_log_action_types_are_translated_in_the_admin_language( @pytest.mark.django_db @override_settings(DEBUG=True) -def test_audit_log_entries_are_not_created_without_a_license( +def test_audit_log_entries_are_created_even_without_a_license( api_client, enterprise_data_fixture ): user = enterprise_data_fixture.create_user() @@ -283,7 +271,7 @@ def test_audit_log_entries_are_not_created_without_a_license( with freeze_time("2023-01-01 12:00:01"): CreateWorkspaceActionType.do(user, "workspace 2") - assert AuditLogEntry.objects.count() == 0 + assert AuditLogEntry.objects.count() == 2 @pytest.mark.django_db @@ -319,7 +307,7 @@ def test_audit_log_entries_are_created_from_actions_and_returned_in_order( } response = api_client.get( - reverse("api:enterprise:admin:audit_log:list"), + reverse("api:enterprise:audit_log:list"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -366,14 +354,14 @@ def test_audit_log_entries_are_translated_in_the_user_language( with patch("django.utils.translation.override") as mock_override: api_client.get( - reverse("api:enterprise:admin:audit_log:list"), + reverse("api:enterprise:audit_log:list"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) mock_override.assert_called_once_with("it") response = api_client.get( - reverse("api:enterprise:admin:audit_log:list"), + reverse("api:enterprise:audit_log:list"), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -450,7 +438,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture): # by user_id response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") + "?user_id=" + str(user.id), + reverse("api:enterprise:audit_log:list") + "?user_id=" + str(user.id), format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -464,7 +452,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture): # by workspace_id response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") + reverse("api:enterprise:audit_log:list") + "?workspace_id=" + str(workspace_1.id), format="json", @@ -480,7 +468,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture): # by action_type response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") + "?action_type=create_group", + reverse("api:enterprise:audit_log:list") + "?action_type=create_group", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -493,8 +481,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture): } response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") - + "?action_type=create_application", + reverse("api:enterprise:audit_log:list") + "?action_type=create_application", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -508,7 +495,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture): # from timestamp response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") + reverse("api:enterprise:audit_log:list") + "?from_timestamp=2023-01-01T12:00:01Z", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", @@ -523,8 +510,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture): # to timestamp response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") - + "?to_timestamp=2023-01-01T12:00:00Z", + reverse("api:enterprise:audit_log:list") + "?to_timestamp=2023-01-01T12:00:00Z", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -549,7 +535,7 @@ def test_audit_log_entries_return_400_for_invalid_values( # an invalid value in the query params should return a 400 response = api_client.get( - reverse("api:enterprise:admin:audit_log:list") + "?user_id=wrong_type", + reverse("api:enterprise:audit_log:list") + "?user_id=wrong_type", format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", ) @@ -576,7 +562,7 @@ def test_audit_log_can_export_to_csv_all_entries( execute=True ): response = api_client.post( - reverse("api:enterprise:admin:audit_log:export"), + reverse("api:enterprise:audit_log:async_export"), data=csv_settings, format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", @@ -631,6 +617,7 @@ def test_audit_log_can_export_to_csv_filtered_entries( "csv_column_separator": "|", "csv_first_row_header": False, "export_charset": "utf-8", + "exclude_columns": "ip_address", } filters = { "filter_user_id": admin_user.id, @@ -642,7 +629,7 @@ def test_audit_log_can_export_to_csv_filtered_entries( # if the action type is invalid, it should return a 400 response = api_client.post( - reverse("api:enterprise:admin:audit_log:export"), + reverse("api:enterprise:audit_log:async_export"), data={**csv_settings, "filter_action_type": "wrong_type"}, format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", @@ -653,7 +640,7 @@ def test_audit_log_can_export_to_csv_filtered_entries( execute=True ): response = api_client.post( - reverse("api:enterprise:admin:audit_log:export"), + reverse("api:enterprise:audit_log:async_export"), data={**csv_settings, **filters}, format="json", HTTP_AUTHORIZATION=f"JWT {admin_token}", @@ -731,7 +718,7 @@ def test_log_entries_still_work_correctly_if_the_action_type_is_removed( action_type_registry.unregister(TemporaryActionType.type) response = api_client.get( - reverse("api:enterprise:admin:audit_log:list"), + reverse("api:enterprise:audit_log:list"), format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -775,7 +762,7 @@ def test_log_entries_still_work_correctly_if_the_action_type_is_removed( action_type_registry.register(TemporaryActionTypeV2()) response = api_client.get( - reverse("api:enterprise:admin:audit_log:list"), + reverse("api:enterprise:audit_log:list"), format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) @@ -805,7 +792,7 @@ def test_log_entries_still_work_correctly_if_the_action_type_is_removed( assert AuditLogEntry.objects.count() == 2 response = api_client.get( - reverse("api:enterprise:admin:audit_log:list"), + reverse("api:enterprise:audit_log:list"), format="json", HTTP_AUTHORIZATION=f"JWT {token}", ) diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/audit_log/test_audit_log_workspace_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/audit_log/test_audit_log_workspace_views.py new file mode 100644 index 000000000..bca004b48 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/audit_log/test_audit_log_workspace_views.py @@ -0,0 +1,287 @@ +from django.shortcuts import reverse +from django.test.utils import override_settings + +import pytest +from freezegun import freeze_time +from rest_framework.status import ( + HTTP_200_OK, + HTTP_202_ACCEPTED, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_402_PAYMENT_REQUIRED, + HTTP_404_NOT_FOUND, +) + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name", ["users", "action_types", "list"]) +@override_settings(DEBUG=True) +def test_workspace_admins_cannot_access_workspace_audit_log_endpoints_without_an_enterprise_license( + api_client, enterprise_data_fixture, url_name +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user) + + response = api_client.get( + reverse(f"api:enterprise:audit_log:{url_name}") + + f"?workspace_id={workspace.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_402_PAYMENT_REQUIRED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_workspace_admins_cannot_export_workspace_audit_log_without_an_enterprise_license( + api_client, enterprise_data_fixture +): + user, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user) + + response = api_client.post( + reverse(f"api:enterprise:audit_log:async_export"), + {"filter_workspace_id": workspace.id}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_402_PAYMENT_REQUIRED + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name", ["users", "action_types", "list"]) +@override_settings(DEBUG=True) +def test_non_admins_cannot_access_workspace_audit_log_endpoints( + api_client, enterprise_data_fixture, url_name +): + enterprise_data_fixture.enable_enterprise() + + admin = enterprise_data_fixture.create_user() + builder, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace( + user=admin, custom_permissions=[(builder, "BUILDER")] + ) + + response = api_client.get( + reverse(f"api:enterprise:audit_log:{url_name}") + + f"?workspace_id={workspace.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_non_admins_cannot_export_workspace_audit_log_to_csv( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + admin = enterprise_data_fixture.create_user() + builder, token = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace( + user=admin, custom_permissions=[(builder, "BUILDER")] + ) + + response = api_client.post( + reverse("api:enterprise:audit_log:async_export"), + {"filter_workspace_id": workspace.id}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name", ["users", "action_types", "list"]) +@override_settings(DEBUG=True) +def test_workspace_audit_log_endpoints_raise_404_if_workspace_doesnt_exist( + api_client, enterprise_data_fixture, url_name +): + enterprise_data_fixture.enable_enterprise() + + _, token = enterprise_data_fixture.create_user_and_token() + + response = api_client.get( + reverse(f"api:enterprise:audit_log:{url_name}") + "?workspace_id=9999", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_GROUP_DOES_NOT_EXIST" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_workspace_audit_log_export_raise_404_if_workspace_doesnt_exist( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + _, token = enterprise_data_fixture.create_user_and_token() + + response = api_client.post( + reverse(f"api:enterprise:audit_log:async_export"), + {"filter_workspace_id": 9999}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_GROUP_DOES_NOT_EXIST" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_workspace_audit_log_user_filter_returns_only_workspace_users( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + admin, token = enterprise_data_fixture.create_user_and_token(email="admin@test.com") + user_wp1 = enterprise_data_fixture.create_user(email="user_wp1@test.com") + user_wp2 = enterprise_data_fixture.create_user(email="user_wp2@test.com") + + workspace_1 = enterprise_data_fixture.create_workspace(users=[admin, user_wp1]) + workspace_2 = enterprise_data_fixture.create_workspace(users=[admin, user_wp2]) + + # no search query should return all users for worspace 1 + response = api_client.get( + reverse("api:enterprise:audit_log:users") + f"?workspace_id={workspace_1.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + {"id": admin.id, "value": admin.email}, + {"id": user_wp1.id, "value": user_wp1.email}, + ], + } + + # no search query should return all users for worspace 2 + response = api_client.get( + reverse("api:enterprise:audit_log:users") + f"?workspace_id={workspace_2.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + {"id": admin.id, "value": admin.email}, + {"id": user_wp2.id, "value": user_wp2.email}, + ], + } + + # searching by email should return only the correct user + response = api_client.get( + reverse("api:enterprise:audit_log:users") + + f"?workspace_id={workspace_1.id}&search=user", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json() == { + "count": 1, + "next": None, + "previous": None, + "results": [{"id": user_wp1.id, "value": "user_wp1@test.com"}], + } + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_workspace_audit_log_can_export_to_csv_filtered_entries( + api_client, + enterprise_data_fixture, + synced_roles, + django_capture_on_commit_callbacks, +): + enterprise_data_fixture.enable_enterprise() + + admin_user, admin_token = enterprise_data_fixture.create_user_and_token( + email="admin@test.com" + ) + workspace = enterprise_data_fixture.create_workspace(user=admin_user) + + csv_settings = { + "csv_column_separator": "|", + "csv_first_row_header": False, + "export_charset": "utf-8", + } + filters = { + "filter_user_id": admin_user.id, + "filter_action_type": "create_application", + "filter_from_timestamp": "2023-01-01T00:00:00Z", + "filter_to_timestamp": "2023-01-03T00:00:00Z", + "filter_workspace_id": workspace.id, + "exclude_columns": "workspace_id,workspace_name", + } + + # if the action type is invalid, it should return a 400 + response = api_client.post( + reverse("api:enterprise:audit_log:async_export"), + data={**csv_settings, "filter_action_type": "wrong_type"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {admin_token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + + # if the workspace id is invalid, it should return a 404 + response = api_client.post( + reverse("api:enterprise:audit_log:async_export"), + data={**csv_settings, **filters, "filter_workspace_id": 9999}, + format="json", + HTTP_AUTHORIZATION=f"JWT {admin_token}", + ) + assert response.status_code == HTTP_404_NOT_FOUND + + with freeze_time("2023-01-02 12:00"), django_capture_on_commit_callbacks( + execute=True + ): + response = api_client.post( + reverse("api:enterprise:audit_log:async_export"), + data={**csv_settings, **filters}, + format="json", + HTTP_AUTHORIZATION=f"JWT {admin_token}", + ) + assert response.status_code == HTTP_202_ACCEPTED, response.json() + job = response.json() + assert job["id"] is not None + assert job["state"] == "pending" + assert job["type"] == "audit_log_export" + + response = api_client.get( + reverse( + "api:jobs:item", + kwargs={"job_id": job["id"]}, + ), + HTTP_AUTHORIZATION=f"JWT {admin_token}", + ) + assert response.status_code == HTTP_200_OK + job = response.json() + assert job["state"] == "finished" + assert job["type"] == "audit_log_export" + for key, value in csv_settings.items(): + assert job[key] == value + for key in [ + "filter_user_id", + "filter_action_type", + "filter_from_timestamp", + "filter_to_timestamp", + ]: + assert job[key] == filters[key] + + assert job["exported_file_name"].endswith(".csv") + assert job["url"].startswith("http://localhost:8000/media/export_files/") + assert job["created_on"] == "2023-01-02T12:00:00Z" + + # These filters are automatically added by the workspace endpoint + assert job["filter_workspace_id"] == workspace.id + assert job["exclude_columns"] == "workspace_id,workspace_name" diff --git a/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_export_job.py b/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_export_job.py index 1be300d14..29f748aa5 100755 --- a/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_export_job.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_export_job.py @@ -9,7 +9,7 @@ import pytest from freezegun import freeze_time from baserow.contrib.database.export.handler import ExportHandler -from baserow.core.actions import CreateWorkspaceActionType +from baserow.core.actions import CreateApplicationActionType, CreateWorkspaceActionType from baserow.core.jobs.constants import JOB_FINISHED from baserow.core.jobs.handler import JobHandler from baserow_enterprise.audit_log.job_types import AuditLogExportJobType @@ -220,3 +220,52 @@ def test_audit_log_export_filters_work_correctly( datetime.strptime("2023-01-01 12:00:08", "%Y-%m-%d %H:%M:%S") ) assert job_type.get_filtered_queryset(job).count() == 0 + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +@patch("baserow.contrib.database.export.handler.default_storage") +def test_audit_log_export_workspace_csv_correctly( + storage_mock, enterprise_data_fixture, synced_roles +): + user, _ = enterprise_data_fixture.create_enterprise_admin_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user) + + with freeze_time("2023-01-01 12:00:00"): + app_1 = CreateApplicationActionType.do(user, workspace, "database", "App 1") + + with freeze_time("2023-01-01 12:00:10"): + app_2 = CreateApplicationActionType.do(user, workspace, "database", "App 2") + + csv_settings = { + "csv_column_separator": ",", + "csv_first_row_header": True, + "export_charset": "utf-8", + "filter_workspace_id": workspace.id, + "exclude_columns": "workspace_id,workspace_name", + } + + stub_file = BytesIO() + storage_mock.open.return_value = stub_file + close = stub_file.close + stub_file.close = lambda: None + + csv_export_job = JobHandler().create_and_start_job( + user, AuditLogExportJobType.type, **csv_settings, sync=True + ) + csv_export_job.refresh_from_db() + assert csv_export_job.state == JOB_FINISHED + + data = stub_file.getvalue().decode(csv_settings["export_charset"]) + bom = "\ufeff" + + assert data == ( + bom + + "User Email,User ID,Action Type,Description,Timestamp,IP Address\r\n" + + f'{user.email},{user.id},Create application,"""{app_2.name}"" ({app_2.id}) database created ' + + f'in group ""{workspace.name}"" ({workspace.id}).",2023-01-01 12:00:10+00:00,\r\n' + + f'{user.email},{user.id},Create application,"""{app_1.name}"" ({app_1.id}) database created ' + + f'in group ""{workspace.name}"" ({workspace.id}).",2023-01-01 12:00:00+00:00,\r\n' + ) + + close() diff --git a/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_handler.py b/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_handler.py index 14fb4b8a8..2c8dc4e1e 100755 --- a/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_handler.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/audit_log/test_audit_log_handler.py @@ -11,25 +11,11 @@ from baserow_enterprise.audit_log.handler import AuditLogHandler from baserow_enterprise.audit_log.models import AuditLogEntry -@pytest.mark.django_db(transaction=True) -@override_settings(DEBUG=True) -def test_actions_are_not_inserted_as_audit_log_entries_without_license( - api_client, enterprise_data_fixture -): - user = enterprise_data_fixture.create_user() - - with freeze_time("2023-01-01 12:00:00"): - CreateWorkspaceActionType.do(user, "workspace 1") - - assert AuditLogEntry.objects.count() == 0 - - @pytest.mark.django_db @override_settings(DEBUG=True) -def test_actions_are_inserted_as_audit_log_entries_with_license( +def test_actions_are_inserted_as_audit_log_entries_and_can_be_deleted_even_without_license( api_client, enterprise_data_fixture, synced_roles ): - enterprise_data_fixture.enable_enterprise() user = enterprise_data_fixture.create_user() with freeze_time("2023-01-01 12:00:00"): @@ -40,6 +26,10 @@ def test_actions_are_inserted_as_audit_log_entries_with_license( assert AuditLogEntry.objects.count() == 2 + AuditLogHandler.delete_entries_older_than(datetime(2023, 1, 1, 13, 0, 0)) + + assert AuditLogEntry.objects.count() == 0 + @pytest.mark.django_db @override_settings(DEBUG=True) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/audit_log.scss b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/audit_log.scss index 105edf939..f0dbacdbe 100755 --- a/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/audit_log.scss +++ b/enterprise/web-frontend/modules/baserow_enterprise/assets/scss/components/audit_log.scss @@ -13,6 +13,11 @@ } } +.audit-log__filters--workspace { + max-width: 1024px; + grid-template-columns: 3fr 3fr minmax(15%, 120px) minmax(15%, 120px) max-content; +} + .audit-log__exported-list { margin-top: 30px; border-top: 1px solid $color-neutral-200; diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/AuditLogSidebarWorkspace.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/AuditLogSidebarWorkspace.vue new file mode 100644 index 000000000..14c55ce0a --- /dev/null +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/AuditLogSidebarWorkspace.vue @@ -0,0 +1,58 @@ +<template> + <li + v-if="hasPermission" + v-tooltip="deactivated ? $t('auditLogSidebarWorkspace.deactivated') : null" + class="tree__item" + :class="{ + 'tree__item--loading': loading, + 'tree__action--disabled': deactivated, + 'tree__action--deactivated': deactivated, + active: $route.matched.some(({ name }) => name === 'workspace-audit-log'), + }" + > + <div class="tree__action"> + <nuxt-link + :event="deactivated || !hasPermission ? null : 'click'" + class="tree__link" + :to="{ + name: 'workspace-audit-log', + params: { workspaceId: workspace.id }, + }" + > + <i class="tree__icon tree__icon--type fas fa-history"></i> + {{ $t('auditLogSidebarWorkspace.title') }} + </nuxt-link> + </div> + </li> +</template> + +<script> +import EnterpriseFeatures from '@baserow_enterprise/features' + +export default { + name: 'AuditLogSidebarWorkspace', + props: { + workspace: { + type: Object, + required: true, + }, + }, + data() { + return { + loading: false, + } + }, + computed: { + deactivated() { + return !this.$hasFeature(EnterpriseFeatures.AUDIT_LOG) + }, + hasPermission() { + return this.$hasPermission( + 'workspace.list_audit_log_entries', + this.workspace, + this.workspace.id + ) + }, + }, +} +</script> diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/admin/modals/AuditLogExportModal.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/admin/modals/AuditLogExportModal.vue index 3d66dac0f..6e4a95b36 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/admin/modals/AuditLogExportModal.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/admin/modals/AuditLogExportModal.vue @@ -18,11 +18,7 @@ <div v-if="job" class="audit-log__exported-list-item"> <div class="audit-log__exported-list-item-info"> <div class="audit-log__exported-list-item-name"> - {{ - $t('auditLogExportModal.exportFilename', { - date: localDate(job.created_on), - }) - }} + {{ getExportedFilenameTitle(job) }} </div> <div class="audit-log__exported-list-item-details"> {{ humanExportedAt(job.created_on) }} @@ -37,11 +33,7 @@ > <div class="audit-log__exported-list-item-info"> <div class="audit-log__exported-list-item-name"> - {{ - $t('auditLogExportModal.exportFilename', { - date: localDate(finishedJob.created_on), - }) - }} + {{ getExportedFilenameTitle(finishedJob) }} </div> <div class="audit-log__exported-list-item-details"> {{ humanExportedAt(finishedJob.created_on) }} @@ -67,8 +59,8 @@ import error from '@baserow/modules/core/mixins/error' import moment from '@baserow/modules/core/moment' import { getHumanPeriodAgoCount } from '@baserow/modules/core/utils/date' import ExportLoadingBar from '@baserow/modules/database/components/export/ExportLoadingBar' -import AuditLogAdminService from '@baserow_enterprise/services/auditLogAdmin' import AuditLogExportForm from '@baserow_enterprise/components/admin/forms/AuditLogExportForm' +import AuditLogAdminService from '@baserow_enterprise/services/auditLog' const MAX_EXPORT_FILES = 4 @@ -81,6 +73,10 @@ export default { type: Object, required: true, }, + workspaceId: { + type: Number, + default: null, + }, }, data() { return { @@ -96,8 +92,13 @@ export default { const jobs = await AuditLogAdminService(this.$client).getLastExportJobs( MAX_EXPORT_FILES ) - this.lastFinishedJobs = jobs.filter((job) => job.state === 'finished') - const runningJob = jobs.find( + const filteredJobs = this.workspaceId + ? jobs.filter((job) => job.filter_workspace_id === this.workspaceId) + : jobs + this.lastFinishedJobs = filteredJobs.filter( + (job) => job.state === 'finished' + ) + const runningJob = filteredJobs.find( (job) => !['failed', 'cancelled', 'finished'].includes(job.state) ) this.job = runningJob || null @@ -129,6 +130,18 @@ export default { getExportedFilename(job) { return job ? `audit_log_${job.created_on}.csv` : '' }, + getExportedFilenameTitle(job) { + if (job.filter_workspace_id) { + return this.$t('auditLogExportModal.exportWorkspaceFilename', { + date: this.localDate(job.created_on), + workspaceId: job.filter_workspace_id, + }) + } else { + return this.$t('auditLogExportModal.exportFilename', { + date: this.localDate(job.created_on), + }) + } + }, humanExportedAt(timestamp) { const { period, count } = getHumanPeriodAgoCount(timestamp) return this.$tc(`datetime.${period}Ago`, count) @@ -154,11 +167,18 @@ export default { value, ]) ) + if (this.workspaceId) { + filters.filter_workspace_id = this.workspaceId + filters.exclude_columns = 'workspace_id,workspace_name' + } try { const { data } = await AuditLogAdminService( this.$client - ).startExportCsvJob({ ...values, ...filters }) + ).startExportCsvJob({ + ...values, + ...filters, + }) this.lastFinishedJobs = this.lastFinishedJobs.slice( 0, MAX_EXPORT_FILES - 1 diff --git a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json index d7ee8628d..5190d8ec6 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json +++ b/enterprise/web-frontend/modules/baserow_enterprise/locales/en.json @@ -8,7 +8,7 @@ "sidebarTooltip": "Your account has access to the enterprise features globally", "rbac": "RBAC", "sso": "SSO", - "deactivated": "Available in enterprise version", + "deactivated": "Available in the advanced/enterprise version", "licenseDescription": "Viewers are free with Baserow Enterprise. If a user has any other role, in any workspace then they will use a paid seat automatically.", "overflowWarning": "You have too many non-viewer users and have used up all of your paid seats. Change users to become viewers on each workspaces members page." }, @@ -61,7 +61,8 @@ "Authentication": "Authentication" }, "auditLog": { - "title": "Audit log", + "adminTitle": "Audit log", + "workspaceTitle": "Audit log - {workspaceName}", "filterUserTitle": "User", "filterWorkspaceTitle": "Workspace", "filterActionTypeTitle": "Event Type", @@ -84,7 +85,8 @@ }, "auditLogExportModal": { "title": "Export to CSV", - "exportFilename": "Audit Log Export - {date}", + "exportFilename": "Admin Audit Log Export - {date}", + "exportWorkspaceFilename": " Workspace ({workspaceId}) Audit Log Export - {date}", "cancelledTitle": "Export failed", "cancelledDescription": "Something went wrong while exporting the audit log. Please try again." }, @@ -274,5 +276,9 @@ }, "snapshotModalWarning": { "message": "Please be aware that a snapshot will include any permissions set on the application and its tables." + }, + "auditLogSidebarWorkspace": { + "title": "Audit log", + "deactivated": "Available in the advanced/enterprise version" } } diff --git a/enterprise/web-frontend/modules/baserow_enterprise/pages/admin/auditLog.vue b/enterprise/web-frontend/modules/baserow_enterprise/pages/auditLog.vue similarity index 62% rename from enterprise/web-frontend/modules/baserow_enterprise/pages/admin/auditLog.vue rename to enterprise/web-frontend/modules/baserow_enterprise/pages/auditLog.vue index 058f36fb8..486902dcd 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/pages/admin/auditLog.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/pages/auditLog.vue @@ -3,6 +3,7 @@ <AuditLogExportModal ref="exportModal" :filters="filters" + :workspace-id="workspaceId" ></AuditLogExportModal> <CrudTable :columns="columns" @@ -13,7 +14,11 @@ row-id-key="id" > <template #title> - {{ $t('auditLog.title') }} + {{ + workspaceId + ? $t('auditLog.workspaceTitle', { workspaceName }) + : $t('auditLog.adminTitle') + }} </template> <template #header-right-side> <button @@ -24,7 +29,10 @@ </button> </template> <template #header-filters> - <div class="audit-log__filters"> + <div + class="audit-log__filters" + :class="{ 'audit-log__filters--workspace': workspaceId }" + > <FilterWrapper :name="$t('auditLog.filterUserTitle')"> <PaginatedDropdown ref="userFilter" @@ -35,7 +43,10 @@ @input="filterUser" ></PaginatedDropdown> </FilterWrapper> - <FilterWrapper :name="$t('auditLog.filterWorkspaceTitle')"> + <FilterWrapper + v-if="!workspaceId" + :name="$t('auditLog.filterWorkspaceTitle')" + > <PaginatedDropdown ref="workspaceFilter" :value="filters.workspace_id" @@ -88,7 +99,7 @@ import _ from 'lodash' import moment from '@baserow/modules/core/moment' import CrudTable from '@baserow/modules/core/components/crudTable/CrudTable' import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown' -import AuditLogAdminService from '@baserow_enterprise/services/auditLogAdmin' +import AuditLogService from '@baserow_enterprise/services/auditLog' import DateFilter from '@baserow_enterprise/components/crudTable/filters/DateFilter' import FilterWrapper from '@baserow_enterprise/components/crudTable/filters/FilterWrapper' import SimpleField from '@baserow/modules/core/components/crudTable/fields/SimpleField' @@ -96,9 +107,10 @@ import LocalDateField from '@baserow/modules/core/components/crudTable/fields/Lo import CrudTableColumn from '@baserow/modules/core/crudTable/crudTableColumn' import LongTextField from '@baserow_enterprise/components/crudTable/fields/LongTextField' import AuditLogExportModal from '@baserow_enterprise/components/admin/modals/AuditLogExportModal' +import EnterpriseFeatures from '@baserow_enterprise/features' export default { - name: 'AuditLogAdminTable', + name: 'AuditLog', components: { AuditLogExportModal, CrudTable, @@ -107,9 +119,40 @@ export default { FilterWrapper, }, layout: 'app', - middleware: 'staff', + middleware: 'authenticated', + asyncData({ app, error, route, store }) { + if (!app.$hasFeature(EnterpriseFeatures.AUDIT_LOG)) { + return error({ + statusCode: 401, + message: 'Available in the advanced/enterprise version', + }) + } + + const workspaceId = route.params.workspaceId + ? parseInt(route.params.workspaceId) + : null + if (workspaceId) { + if ( + !app.$hasPermission( + 'workspace.list_audit_log_entries', + store.getters['workspace/get'](workspaceId), + workspaceId + ) + ) { + return error({ statusCode: 404, message: 'Page not found' }) + } + } else if (!store.getters['auth/isStaff']) { + return error({ statusCode: 403, message: 'Forbidden.' }) + } + + return { workspaceId } + }, data() { - this.columns = [ + const filters = {} + const params = this.$route.params + const workspaceId = params.workspaceId ? parseInt(params.workspaceId) : null + + const columns = [ new CrudTableColumn( 'user', () => this.$t('auditLog.user'), @@ -120,64 +163,85 @@ export default { {}, '15' ), - new CrudTableColumn( - 'workspace', - () => this.$t('auditLog.workspace'), - SimpleField, - true, - false, - false, - {}, - '15' - ), - new CrudTableColumn( - 'type', - () => this.$t('auditLog.actionType'), - SimpleField, - true, - false, - false, - {}, - '10' - ), - new CrudTableColumn( - 'description', - () => this.$t('auditLog.description'), - LongTextField, - false, - false, - false, - {}, - '40' - ), - new CrudTableColumn( - 'timestamp', - () => this.$t('auditLog.timestamp'), - LocalDateField, - true, - false, - false, - { dateTimeFormat: 'L LTS' }, - '10' - ), - new CrudTableColumn( - 'ip_address', - () => this.$t('auditLog.ip_address'), - SimpleField, - true, - false, - false, - {}, - '10' - ), ] - this.service = AuditLogAdminService(this.$client) + + if (!workspaceId) { + columns.push( + new CrudTableColumn( + 'workspace', + () => this.$t('auditLog.workspace'), + SimpleField, + true, + false, + false, + {}, + '15' + ) + ) + } else { + filters.workspace_id = workspaceId + } + + columns.push( + ...[ + new CrudTableColumn( + 'type', + () => this.$t('auditLog.actionType'), + SimpleField, + true, + false, + false, + {}, + '10' + ), + new CrudTableColumn( + 'description', + () => this.$t('auditLog.description'), + LongTextField, + false, + false, + false, + {}, + '40' + ), + new CrudTableColumn( + 'timestamp', + () => this.$t('auditLog.timestamp'), + LocalDateField, + true, + false, + false, + { dateTimeFormat: 'L LTS' }, + '10' + ), + new CrudTableColumn( + 'ip_address', + () => this.$t('auditLog.ip_address'), + SimpleField, + true, + false, + false, + {}, + '10' + ), + ] + ) + + this.columns = columns + this.service = AuditLogService(this.$client) + return { - filters: {}, + filters, dateTimeFormat: 'YYYY-MM-DDTHH:mm:ss.SSSZ', } }, computed: { + workspaceName() { + const selectedWorkspace = this.$store.getters['workspace/get']( + this.workspaceId + ) + return selectedWorkspace ? selectedWorkspace.name : '' + }, disableDates() { const minimumDate = moment('2023-01-01', 'YYYY-MM-DD') const maximumDate = moment().add(1, 'day').endOf('day') @@ -186,6 +250,23 @@ export default { from: maximumDate.toDate(), } }, + selectedWorkspaceId() { + try { + return this.$store.getters['workspace/selectedId'] + } catch (e) { + return null + } + }, + }, + watch: { + selectedWorkspaceId(newValue, oldValue) { + if (newValue !== oldValue && this.workspaceId) { + this.$router.push({ + name: newValue ? 'workspace-audit-log' : 'dashboard', + params: { workspaceId: newValue }, + }) + } + }, }, methods: { clearFilters() { @@ -196,7 +277,7 @@ export default { 'fromTimestampFilter', 'toTimestampFilter', ]) { - this.$refs[filterRef].clear() + this.$refs[filterRef]?.clear() } this.filters = {} }, @@ -215,7 +296,7 @@ export default { this.setFilter('user_id', userId) }, fetchUsers(page, search) { - return this.service.fetchUsers(page, search) + return this.service.fetchUsers(page, search, this.workspaceId) }, filterWorkspace(workspaceId) { this.setFilter('workspace_id', workspaceId) @@ -224,7 +305,7 @@ export default { return this.service.fetchWorkspaces(page, search) }, fetchActionTypes(page, search) { - return this.service.fetchActionTypes(page, search) + return this.service.fetchActionTypes(page, search, this.workspaceId) }, filterActionType(actionTypeId) { this.setFilter('action_type', actionTypeId) diff --git a/enterprise/web-frontend/modules/baserow_enterprise/plugins.js b/enterprise/web-frontend/modules/baserow_enterprise/plugins.js index a9373360b..0b687d7c9 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/plugins.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/plugins.js @@ -1,5 +1,6 @@ import { BaserowPlugin } from '@baserow/modules/core/plugins' import ChatwootSupportSidebarWorkspace from '@baserow_enterprise/components/ChatwootSupportSidebarWorkspace' +import AuditLogSidebarWorkspace from '@baserow_enterprise/components/AuditLogSidebarWorkspace' import MemberRolesDatabaseContextItem from '@baserow_enterprise/components/member-roles/MemberRolesDatabaseContextItem' import MemberRolesTableContextItem from '@baserow_enterprise/components/member-roles/MemberRolesTableContextItem' import EnterpriseFeatures from '@baserow_enterprise/features' @@ -10,12 +11,17 @@ export class EnterprisePlugin extends BaserowPlugin { return 'enterprise' } - getSidebarWorkspaceComponent(workspace) { + getSidebarWorkspaceComponents(workspace) { const supportEnabled = this.app.$hasFeature( EnterpriseFeatures.SUPPORT, workspace.id ) - return supportEnabled ? ChatwootSupportSidebarWorkspace : null + const sidebarItems = [] + if (supportEnabled) { + sidebarItems.push(ChatwootSupportSidebarWorkspace) + } + sidebarItems.push(AuditLogSidebarWorkspace) + return sidebarItems } getAdditionalDatabaseContextComponents(workspace, database) { diff --git a/enterprise/web-frontend/modules/baserow_enterprise/routes.js b/enterprise/web-frontend/modules/baserow_enterprise/routes.js index 53b0ab93a..b89976f11 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/routes.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/routes.js @@ -19,6 +19,11 @@ export const routes = [ { name: 'admin-audit-log', path: '/admin/audit-log', - component: path.resolve(__dirname, 'pages/admin/auditLog.vue'), + component: path.resolve(__dirname, 'pages/auditLog.vue'), + }, + { + name: 'workspace-audit-log', + path: '/workspace/:workspaceId/audit-log', + component: path.resolve(__dirname, 'pages/auditLog.vue'), }, ] diff --git a/enterprise/web-frontend/modules/baserow_enterprise/services/auditLogAdmin.js b/enterprise/web-frontend/modules/baserow_enterprise/services/auditLog.js similarity index 65% rename from enterprise/web-frontend/modules/baserow_enterprise/services/auditLogAdmin.js rename to enterprise/web-frontend/modules/baserow_enterprise/services/auditLog.js index 4e31c8d7c..bca3c6b00 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/services/auditLogAdmin.js +++ b/enterprise/web-frontend/modules/baserow_enterprise/services/auditLog.js @@ -2,36 +2,44 @@ import baseService from '@baserow/modules/core/crudTable/baseService' import jobService from '@baserow/modules/core/services/job' export default (client) => { - return Object.assign(baseService(client, '/admin/audit-log/'), { - fetchUsers(page, search) { - const usersUrl = '/admin/audit-log/users/' + return Object.assign(baseService(client, `/audit-log/`), { + fetchUsers(page, search, workspaceId = null) { + const usersUrl = `/audit-log/users/` const userPaginatedService = baseService(client, usersUrl) - return userPaginatedService.fetch(usersUrl, page, search, [], []) + const filters = {} + if (workspaceId) { + filters.workspace_id = workspaceId + } + return userPaginatedService.fetch(usersUrl, page, search, [], filters) }, fetchWorkspaces(page, search) { - const workspacesUrl = '/admin/audit-log/workspaces/' + const workspacesUrl = `/audit-log/workspaces/` const workspacePaginatedService = baseService(client, workspacesUrl) return workspacePaginatedService.fetch( workspacesUrl, page, search, [], - [] + {} ) }, - fetchActionTypes(page, search) { - const actionTypesUrl = '/admin/audit-log/action-types/' + fetchActionTypes(page, search, workspaceId = null) { + const actionTypesUrl = `/audit-log/action-types/` const actionTypePaginatedService = baseService(client, actionTypesUrl) + const filters = {} + if (workspaceId) { + filters.workspace_id = workspaceId + } return actionTypePaginatedService.fetch( actionTypesUrl, page, search, [], - [] + filters ) }, startExportCsvJob(data) { - return client.post('/admin/audit-log/export/', data) + return client.post(`/audit-log/export/`, data) }, getExportJobInfo(jobId) { return jobService(client).get(jobId) diff --git a/premium/backend/src/baserow_premium/api/admin/views.py b/premium/backend/src/baserow_premium/api/admin/views.py index 8220e751d..8577773d0 100755 --- a/premium/backend/src/baserow_premium/api/admin/views.py +++ b/premium/backend/src/baserow_premium/api/admin/views.py @@ -23,10 +23,9 @@ from baserow.api.pagination import PageNumberPagination from baserow.api.schemas import get_error_schema -class AdminListingView( +class APIListingView( APIView, SearchableViewMixin, SortableViewMixin, FilterableViewMixin ): - permission_classes = (IsAdminUser,) serializer_class = None search_fields: List[str] = ["id"] filters_field_mapping: Dict[str, str] = {} @@ -72,32 +71,37 @@ class AdminListingView( @staticmethod def get_extend_schema_parameters( - name, serializer_class, search_fields, sort_field_mapping + name, serializer_class, search_fields, sort_field_mapping, extra_parameters=None ): """ Returns the schema properties that can be used in in the @extend_schema decorator. """ - fields = sort_field_mapping.keys() - all_fields = ", ".join(fields) - field_name_1 = "field_1" - field_name_2 = "field_2" - for i, field in enumerate(fields): - if i == 0: - field_name_1 = field - if i == 1: - field_name_2 = field - - return { - "parameters": [ + parameters = [] + if search_fields: + parameters.append( OpenApiParameter( name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, - description=f"If provided only {name} that match the query will " - f"be returned.", - ), + description=f"If provided only {name} with {' or '.join(search_fields)} " + "that match the query will be returned.", + ) + ) + + if sort_field_mapping: + fields = sort_field_mapping.keys() + all_fields = ", ".join(fields) + field_name_1 = "field_1" + field_name_2 = "field_2" + for i, field in enumerate(fields): + if i == 0: + field_name_1 = field + if i == 1: + field_name_2 = field + + parameters.append( OpenApiParameter( name="sorts", location=OpenApiParameter.QUERY, @@ -105,13 +109,18 @@ class AdminListingView( description=f"A comma separated string of attributes to sort by, " f"each attribute must be prefixed with `+` for a descending " f"sort or a `-` for an ascending sort. The accepted attribute " - f"names are: {all_fields}. For example `sorts=-{field_name_1}," + f"names are: `{all_fields}`. For example `sorts=-{field_name_1}," f"-{field_name_2}` will sort the {name} first by descending " f"{field_name_1} and then ascending {field_name_2}. A sort" f"parameter with multiple instances of the same sort attribute " f"will respond with the ERROR_INVALID_SORT_ATTRIBUTE " f"error.", ), + ) + + return { + "parameters": [ + *parameters, OpenApiParameter( name="page", location=OpenApiParameter.QUERY, @@ -125,6 +134,7 @@ class AdminListingView( description=f"Defines how many {name} should be returned per " f"page.", ), + *(extra_parameters or []), ], "responses": { 200: serializer_class(many=True), @@ -139,3 +149,7 @@ class AdminListingView( 401: None, }, } + + +class AdminListingView(APIListingView): + permission_classes = (IsAdminUser,) diff --git a/web-frontend/modules/core/assets/scss/components/data_table.scss b/web-frontend/modules/core/assets/scss/components/data_table.scss index b2cff184d..5f8a55e76 100644 --- a/web-frontend/modules/core/assets/scss/components/data_table.scss +++ b/web-frontend/modules/core/assets/scss/components/data_table.scss @@ -35,6 +35,8 @@ } .data-table__title { + @extend %ellipsis; + font-size: 24px; line-height: 32px; margin: 0; diff --git a/web-frontend/modules/core/components/sidebar/Sidebar.vue b/web-frontend/modules/core/components/sidebar/Sidebar.vue index 1c18f21b7..eddfa3dda 100644 --- a/web-frontend/modules/core/components/sidebar/Sidebar.vue +++ b/web-frontend/modules/core/components/sidebar/Sidebar.vue @@ -448,8 +448,8 @@ export default { }, sidebarWorkspaceComponents() { return Object.values(this.$registry.getAll('plugin')) - .map((plugin) => - plugin.getSidebarWorkspaceComponent(this.selectedWorkspace) + .flatMap((plugin) => + plugin.getSidebarWorkspaceComponents(this.selectedWorkspace) ) .filter((component) => component !== null) }, diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json index ea1ed1388..f8c164264 100644 --- a/web-frontend/modules/core/locales/en.json +++ b/web-frontend/modules/core/locales/en.json @@ -144,6 +144,7 @@ "workspaceContext": { "renameWorkspace": "Rename workspace", "members": "Members", + "auditLog": "Audit log", "viewTrash": "View trash", "leaveWorkspace": "Leave workspace", "deleteWorkspace": "Delete workspace" diff --git a/web-frontend/modules/core/plugins.js b/web-frontend/modules/core/plugins.js index 5af0dcff9..7a8d7e4fa 100644 --- a/web-frontend/modules/core/plugins.js +++ b/web-frontend/modules/core/plugins.js @@ -36,7 +36,7 @@ export class BaserowPlugin extends Registerable { * Every registered plugin can display an additional item in the sidebar within * the workspace context. */ - getSidebarWorkspaceComponent(workspace) { + getSidebarWorkspaceComponents(workspace) { return null } diff --git a/web-frontend/modules/database/pages/table.vue b/web-frontend/modules/database/pages/table.vue index 5ea8a946d..44eeee875 100644 --- a/web-frontend/modules/database/pages/table.vue +++ b/web-frontend/modules/database/pages/table.vue @@ -39,6 +39,7 @@ export default { beforeRouteLeave(to, from, next) { this.$store.dispatch('view/unselect') this.$store.dispatch('table/unselect') + this.$store.dispatch('application/unselect') next() }, async beforeRouteUpdate(to, from, next) {