mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-14 17:18:33 +00:00
Resolve "Workspace level audit log feature"
This commit is contained in:
parent
96c0ca83a0
commit
35a535e48a
35 changed files with 1315 additions and 490 deletions
changelog/entries/unreleased/feature
enterprise
backend
web-frontend/modules/baserow_enterprise
premium/backend/src/baserow_premium/api/admin
web-frontend/modules
core
database/pages
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"type": "feature",
|
||||||
|
"message": "Introduce Workspace level audit log feature",
|
||||||
|
"issue_number": 1901,
|
||||||
|
"bullet_points": [],
|
||||||
|
"created_at": "2023-08-09"
|
||||||
|
}
|
|
@ -1,26 +1,21 @@
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
|
|
||||||
from .views import (
|
from baserow_enterprise.api.audit_log.views import (
|
||||||
AdminAuditLogActionTypeFilterView,
|
|
||||||
AdminAuditLogUserFilterView,
|
|
||||||
AdminAuditLogView,
|
|
||||||
AdminAuditLogWorkspaceFilterView,
|
|
||||||
AsyncAuditLogExportView,
|
AsyncAuditLogExportView,
|
||||||
|
AuditLogActionTypeFilterView,
|
||||||
|
AuditLogUserFilterView,
|
||||||
|
AuditLogView,
|
||||||
|
AuditLogWorkspaceFilterView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "baserow_enterprise.api.audit_log"
|
app_name = "baserow_enterprise.api.audit_log"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
re_path(r"^$", AdminAuditLogView.as_view(), name="list"),
|
re_path(r"^$", AuditLogView.as_view(), name="list"),
|
||||||
re_path(r"users/$", AdminAuditLogUserFilterView.as_view(), name="users"),
|
re_path(r"users/$", AuditLogUserFilterView.as_view(), name="users"),
|
||||||
|
re_path(r"workspaces/$", AuditLogWorkspaceFilterView.as_view(), name="workspaces"),
|
||||||
re_path(
|
re_path(
|
||||||
r"workspaces/$", AdminAuditLogWorkspaceFilterView.as_view(), name="workspaces"
|
r"action-types/$", AuditLogActionTypeFilterView.as_view(), name="action_types"
|
||||||
),
|
|
||||||
# GroupDeprecation
|
|
||||||
re_path(
|
|
||||||
r"action-types/$",
|
|
||||||
AdminAuditLogActionTypeFilterView.as_view(),
|
|
||||||
name="action_types",
|
|
||||||
),
|
),
|
||||||
re_path(r"export/$", AsyncAuditLogExportView.as_view(), name="export"),
|
re_path(r"export/$", AsyncAuditLogExportView.as_view(), name="export"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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)
|
|
|
@ -1,4 +1,5 @@
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import translation
|
||||||
from django.utils.functional import lazy
|
from django.utils.functional import lazy
|
||||||
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
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()
|
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):
|
class AuditLogSerializer(serializers.ModelSerializer):
|
||||||
user = serializers.SerializerMethodField()
|
user = serializers.SerializerMethodField()
|
||||||
group = serializers.SerializerMethodField() # GroupDeprecation
|
group = serializers.SerializerMethodField() # GroupDeprecation
|
||||||
|
@ -34,14 +54,6 @@ class AuditLogSerializer(serializers.ModelSerializer):
|
||||||
description = serializers.SerializerMethodField()
|
description = serializers.SerializerMethodField()
|
||||||
timestamp = serializers.DateTimeField(source="action_timestamp")
|
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)
|
@extend_schema_field(OpenApiTypes.STR)
|
||||||
def get_user(self, instance):
|
def get_user(self, instance):
|
||||||
return render_user(instance.user_id, instance.user_email)
|
return render_user(instance.user_id, instance.user_email)
|
||||||
|
@ -54,6 +66,14 @@ class AuditLogSerializer(serializers.ModelSerializer):
|
||||||
def get_description(self, instance):
|
def get_description(self, instance):
|
||||||
return instance.description
|
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:
|
class Meta:
|
||||||
model = AuditLogEntry
|
model = AuditLogEntry
|
||||||
fields = (
|
fields = (
|
||||||
|
@ -98,6 +118,42 @@ class AuditLogActionTypeSerializer(serializers.Serializer):
|
||||||
return render_action_type(instance.type)
|
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(
|
AuditLogExportJobRequestSerializer = job_type_registry.get(
|
||||||
AuditLogExportJobType.type
|
AuditLogExportJobType.type
|
||||||
).get_serializer_class(
|
).get_serializer_class(
|
||||||
|
@ -112,18 +168,3 @@ AuditLogExportJobResponseSerializer = job_type_registry.get(
|
||||||
base_class=serializers.Serializer,
|
base_class=serializers.Serializer,
|
||||||
meta_ref_name="SingleAuditLogExportJobResponseSerializer",
|
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)
|
|
25
enterprise/backend/src/baserow_enterprise/api/audit_log/urls.py
Executable file
25
enterprise/backend/src/baserow_enterprise/api/audit_log/urls.py
Executable file
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
345
enterprise/backend/src/baserow_enterprise/api/audit_log/views.py
Executable file
345
enterprise/backend/src/baserow_enterprise/api/audit_log/views.py
Executable file
|
@ -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)
|
|
@ -1,6 +1,7 @@
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
from .admin import urls as admin_urls
|
from .admin import urls as admin_urls
|
||||||
|
from .audit_log import urls as audit_log_urls
|
||||||
from .role import urls as role_urls
|
from .role import urls as role_urls
|
||||||
from .sso import urls as sso_urls
|
from .sso import urls as sso_urls
|
||||||
from .teams import urls as teams_urls
|
from .teams import urls as teams_urls
|
||||||
|
@ -12,4 +13,5 @@ urlpatterns = [
|
||||||
path("role/", include(role_urls, namespace="role")),
|
path("role/", include(role_urls, namespace="role")),
|
||||||
path("admin/", include(admin_urls, namespace="admin")),
|
path("admin/", include(admin_urls, namespace="admin")),
|
||||||
path("sso/", include(sso_urls, namespace="sso")),
|
path("sso/", include(sso_urls, namespace="sso")),
|
||||||
|
path("audit-log/", include(audit_log_urls, namespace="audit_log")),
|
||||||
]
|
]
|
||||||
|
|
|
@ -10,6 +10,9 @@ class BaserowEnterpriseConfig(AppConfig):
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from baserow.core.jobs.registries import job_type_registry
|
from baserow.core.jobs.registries import job_type_registry
|
||||||
from baserow_enterprise.audit_log.job_types import AuditLogExportJobType
|
from baserow_enterprise.audit_log.job_types import AuditLogExportJobType
|
||||||
|
from baserow_enterprise.audit_log.operations import (
|
||||||
|
ListWorkspaceAuditLogEntriesOperationType,
|
||||||
|
)
|
||||||
|
|
||||||
job_type_registry.register(AuditLogExportJobType())
|
job_type_registry.register(AuditLogExportJobType())
|
||||||
|
|
||||||
|
@ -101,6 +104,7 @@ class BaserowEnterpriseConfig(AppConfig):
|
||||||
operation_type_registry.register(UpdateRoleApplicationOperationType())
|
operation_type_registry.register(UpdateRoleApplicationOperationType())
|
||||||
operation_type_registry.register(ReadRoleTableOperationType())
|
operation_type_registry.register(ReadRoleTableOperationType())
|
||||||
operation_type_registry.register(UpdateRoleTableOperationType())
|
operation_type_registry.register(UpdateRoleTableOperationType())
|
||||||
|
operation_type_registry.register(ListWorkspaceAuditLogEntriesOperationType())
|
||||||
|
|
||||||
from baserow.core.registries import subject_type_registry
|
from baserow.core.registries import subject_type_registry
|
||||||
|
|
||||||
|
|
|
@ -3,13 +3,10 @@ from typing import Any, Dict, Optional, Type
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
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.api.sessions import get_user_remote_addr_ip
|
||||||
from baserow.core.action.registries import ActionType
|
from baserow.core.action.registries import ActionType
|
||||||
from baserow.core.action.signals import ActionCommandType
|
from baserow.core.action.signals import ActionCommandType
|
||||||
from baserow.core.models import Workspace
|
from baserow.core.models import Workspace
|
||||||
from baserow_enterprise.features import AUDIT_LOG
|
|
||||||
|
|
||||||
from .models import AuditLogEntry
|
from .models import AuditLogEntry
|
||||||
|
|
||||||
|
@ -44,12 +41,8 @@ class AuditLogHandler:
|
||||||
is sent so it can be used to identify other resources created at the
|
is sent so it can be used to identify other resources created at the
|
||||||
same time (i.e. row_history entries).
|
same time (i.e. row_history entries).
|
||||||
:param workspace: The workspace that the action was performed on.
|
: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
|
workspace_id, workspace_name = None, None
|
||||||
if workspace is not None:
|
if workspace is not None:
|
||||||
workspace_id = workspace.id
|
workspace_id = workspace.id
|
||||||
|
|
|
@ -30,6 +30,61 @@ from baserow_enterprise.features import AUDIT_LOG
|
||||||
|
|
||||||
from .models import AuditLogEntry, AuditLogExportJob
|
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):
|
class AuditLogExportJobType(JobType):
|
||||||
type = "audit_log_export"
|
type = "audit_log_export"
|
||||||
|
@ -46,24 +101,16 @@ class AuditLogExportJobType(JobType):
|
||||||
"filter_action_type",
|
"filter_action_type",
|
||||||
"filter_from_timestamp",
|
"filter_from_timestamp",
|
||||||
"filter_to_timestamp",
|
"filter_to_timestamp",
|
||||||
|
"exclude_columns",
|
||||||
]
|
]
|
||||||
|
|
||||||
serializer_field_names = [
|
serializer_field_names = [
|
||||||
"csv_column_separator",
|
*request_serializer_field_names,
|
||||||
"csv_first_row_header",
|
|
||||||
"export_charset",
|
|
||||||
"filter_user_id",
|
|
||||||
"filter_workspace_id",
|
|
||||||
"filter_action_type",
|
|
||||||
"filter_from_timestamp",
|
|
||||||
"filter_to_timestamp",
|
|
||||||
"created_on",
|
"created_on",
|
||||||
"exported_file_name",
|
"exported_file_name",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
serializer_field_overrides = {
|
base_serializer_field_overrides = {
|
||||||
# Map to the python encoding aliases at the same time by using a
|
|
||||||
# DisplayChoiceField
|
|
||||||
"export_charset": DisplayChoiceField(
|
"export_charset": DisplayChoiceField(
|
||||||
choices=SUPPORTED_EXPORT_CHARSETS,
|
choices=SUPPORTED_EXPORT_CHARSETS,
|
||||||
default="utf-8",
|
default="utf-8",
|
||||||
|
@ -106,10 +153,32 @@ class AuditLogExportJobType(JobType):
|
||||||
required=False,
|
required=False,
|
||||||
help_text="Optional: The end date to filter the audit log by.",
|
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(
|
"created_on": serializers.DateTimeField(
|
||||||
read_only=True,
|
read_only=True,
|
||||||
help_text="The date and time when the export job was created.",
|
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):
|
def before_delete(self, job):
|
||||||
|
@ -135,18 +204,12 @@ class AuditLogExportJobType(JobType):
|
||||||
if job.export_charset == "utf-8":
|
if job.export_charset == "utf-8":
|
||||||
file.write(b"\xef\xbb\xbf")
|
file.write(b"\xef\xbb\xbf")
|
||||||
|
|
||||||
field_header_mapping = OrderedDict(
|
exclude_columns = job.exclude_columns.split(",") if job.exclude_columns else []
|
||||||
{
|
field_header_mapping = {
|
||||||
"user_email": _("User Email"),
|
k: v["descr"]
|
||||||
"user_id": _("User ID"),
|
for (k, v) in AUDIT_LOG_CSV_COLUMN_NAMES.items()
|
||||||
"workspace_name": _("Group Name"), # GroupDeprecation
|
if k not in exclude_columns
|
||||||
"workspace_id": _("Group ID"),
|
|
||||||
"type": _("Action Type"),
|
|
||||||
"description": _("Description"),
|
|
||||||
"action_timestamp": _("Timestamp"),
|
|
||||||
"ip_address": _("IP Address"),
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
writer = csv.writer(
|
writer = csv.writer(
|
||||||
file,
|
file,
|
||||||
|
@ -158,7 +221,11 @@ class AuditLogExportJobType(JobType):
|
||||||
if job.csv_first_row_header:
|
if job.csv_first_row_header:
|
||||||
writer.writerow(field_header_mapping.values())
|
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)
|
paginator = Paginator(queryset.all(), 2000)
|
||||||
export_progress = ChildProgressBuilder.build(
|
export_progress = ChildProgressBuilder.build(
|
||||||
progress.create_child_builder(represents_progress=progress.total),
|
progress.create_child_builder(represents_progress=progress.total),
|
||||||
|
|
|
@ -32,10 +32,10 @@ class AuditLogEntry(CreatedAndUpdatedOnMixin, models.Model):
|
||||||
REDO = ActionCommandType.UNDO.name, _("REDONE")
|
REDO = ActionCommandType.UNDO.name, _("REDONE")
|
||||||
|
|
||||||
user_id = models.PositiveIntegerField(null=True)
|
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_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_uuid = models.CharField(max_length=36, null=True)
|
||||||
action_type = models.TextField()
|
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.
|
# 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
|
# 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
|
# able to fallback to them and show the original string in case. NOTE: if
|
||||||
# the _('$original_description') has been removed from the codebase, the
|
# also the _('$original_description') has been removed from the codebase,
|
||||||
# entry won't be translated anymore.
|
# the entry won't be translated anymore.
|
||||||
original_action_short_descr = models.TextField(null=True, blank=True)
|
original_action_short_descr = models.TextField(null=True, blank=True)
|
||||||
original_action_long_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)
|
original_action_context_descr = models.TextField(null=True, blank=True)
|
||||||
|
@ -147,3 +147,8 @@ class AuditLogExportJob(Job):
|
||||||
null=True,
|
null=True,
|
||||||
help_text="The CSV file containing the filtered audit log entries.",
|
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.",
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
from baserow.core.operations import WorkspaceCoreOperationType
|
||||||
|
|
||||||
|
|
||||||
|
class ListWorkspaceAuditLogEntriesOperationType(WorkspaceCoreOperationType):
|
||||||
|
type = "workspace.list_audit_log_entries"
|
|
@ -4,8 +4,6 @@ from typing import Any, Dict, Optional, Type
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from baserow_premium.license.exceptions import FeaturesNotAvailableError
|
|
||||||
|
|
||||||
from baserow.core.action.registries import ActionType
|
from baserow.core.action.registries import ActionType
|
||||||
from baserow.core.action.signals import ActionCommandType, action_done
|
from baserow.core.action.signals import ActionCommandType, action_done
|
||||||
from baserow.core.models import Workspace
|
from baserow.core.models import Workspace
|
||||||
|
@ -25,7 +23,6 @@ def log_action(
|
||||||
workspace: Optional[Workspace] = None,
|
workspace: Optional[Workspace] = None,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
AuditLogHandler.log_action(
|
AuditLogHandler.log_action(
|
||||||
user,
|
user,
|
||||||
action_type,
|
action_type,
|
||||||
|
@ -36,5 +33,3 @@ def log_action(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
except FeaturesNotAvailableError:
|
|
||||||
pass
|
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -179,6 +179,9 @@ from baserow.core.trash.operations import (
|
||||||
ReadApplicationTrashOperationType,
|
ReadApplicationTrashOperationType,
|
||||||
ReadWorkspaceTrashOperationType,
|
ReadWorkspaceTrashOperationType,
|
||||||
)
|
)
|
||||||
|
from baserow_enterprise.audit_log.operations import (
|
||||||
|
ListWorkspaceAuditLogEntriesOperationType,
|
||||||
|
)
|
||||||
from baserow_enterprise.role.constants import (
|
from baserow_enterprise.role.constants import (
|
||||||
ADMIN_ROLE_UID,
|
ADMIN_ROLE_UID,
|
||||||
BUILDER_ROLE_UID,
|
BUILDER_ROLE_UID,
|
||||||
|
@ -411,5 +414,6 @@ default_roles[ADMIN_ROLE_UID].extend(
|
||||||
ListSnapshotsApplicationOperationType,
|
ListSnapshotsApplicationOperationType,
|
||||||
DeleteApplicationSnapshotOperationType,
|
DeleteApplicationSnapshotOperationType,
|
||||||
RestoreDomainOperationType,
|
RestoreDomainOperationType,
|
||||||
|
ListWorkspaceAuditLogEntriesOperationType,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -28,15 +28,23 @@ from baserow_enterprise.audit_log.models import AuditLogEntry
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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)
|
@override_settings(DEBUG=True)
|
||||||
def test_admins_can_not_access_audit_log_endpoints_without_an_enterprise_license(
|
def test_admins_cannot_access_audit_log_endpoints_without_an_enterprise_license(
|
||||||
api_client, enterprise_data_fixture, url_name
|
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(
|
response = getattr(api_client, method)(
|
||||||
reverse(f"api:enterprise:admin:audit_log:{url_name}"),
|
reverse(f"api:enterprise:audit_log:{url_name}"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
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.django_db
|
||||||
@pytest.mark.parametrize("url_name", ["users", "workspaces", "action_types", "list"])
|
@pytest.mark.parametrize("url_name", ["users", "workspaces", "action_types", "list"])
|
||||||
@override_settings(DEBUG=True)
|
@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
|
api_client, enterprise_data_fixture, url_name
|
||||||
):
|
):
|
||||||
enterprise_data_fixture.enable_enterprise()
|
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(
|
response = api_client.get(
|
||||||
reverse(f"api:enterprise:admin:audit_log:{url_name}"),
|
reverse(f"api:enterprise:audit_log:{url_name}"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||||
)
|
)
|
||||||
|
@ -63,30 +71,13 @@ def test_non_admins_can_not_access_audit_log_endpoints(
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(DEBUG=True)
|
@override_settings(DEBUG=True)
|
||||||
def test_admins_can_not_export_audit_log_to_csv_without_an_enterprise_license(
|
def test_non_admins_cannot_export_audit_log_to_csv(api_client, enterprise_data_fixture):
|
||||||
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
|
|
||||||
):
|
|
||||||
enterprise_data_fixture.enable_enterprise()
|
enterprise_data_fixture.enable_enterprise()
|
||||||
|
|
||||||
user, token = enterprise_data_fixture.create_user_and_token()
|
user, token = enterprise_data_fixture.create_user_and_token()
|
||||||
|
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
reverse(f"api:enterprise:admin:audit_log:export"),
|
reverse("api:enterprise:audit_log:async_export"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
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
|
# no search query should return all users
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:users"),
|
reverse("api:enterprise:audit_log:users"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# searching by email should return only the correct user
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:users") + "?search=admin",
|
reverse("api:enterprise:audit_log:users") + "?search=admin",
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# no search query should return all workspaces
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:workspaces"),
|
reverse("api:enterprise:audit_log:workspaces"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# searching by name should return only the correct workspace
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:workspaces") + "?search=1",
|
reverse("api:enterprise:audit_log:workspaces") + "?search=1",
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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``
|
# no search query should return all the available action types``
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:action_types"),
|
reverse("api:enterprise:audit_log:action_types"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# searching by name should return only the correct action_type
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:action_types")
|
reverse("api:enterprise:audit_log:action_types")
|
||||||
+ f"?search=create+application",
|
+ f"?search=create+application",
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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:
|
with patch("django.utils.translation.override") as mock_override:
|
||||||
api_client.get(
|
api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:action_types"),
|
reverse("api:enterprise:audit_log:action_types"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# the search works in the user language
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
(
|
(reverse("api:enterprise:audit_log:action_types") + f"?search=crea+progetto"),
|
||||||
reverse("api:enterprise:admin:audit_log:action_types")
|
|
||||||
+ f"?search=crea+progetto"
|
|
||||||
),
|
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
@pytest.mark.django_db
|
||||||
@override_settings(DEBUG=True)
|
@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
|
api_client, enterprise_data_fixture
|
||||||
):
|
):
|
||||||
user = enterprise_data_fixture.create_user()
|
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"):
|
with freeze_time("2023-01-01 12:00:01"):
|
||||||
CreateWorkspaceActionType.do(user, "workspace 2")
|
CreateWorkspaceActionType.do(user, "workspace 2")
|
||||||
|
|
||||||
assert AuditLogEntry.objects.count() == 0
|
assert AuditLogEntry.objects.count() == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list"),
|
reverse("api:enterprise:audit_log:list"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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:
|
with patch("django.utils.translation.override") as mock_override:
|
||||||
api_client.get(
|
api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list"),
|
reverse("api:enterprise:audit_log:list"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
||||||
)
|
)
|
||||||
mock_override.assert_called_once_with("it")
|
mock_override.assert_called_once_with("it")
|
||||||
|
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list"),
|
reverse("api:enterprise:audit_log:list"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# by user_id
|
||||||
response = api_client.get(
|
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",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# by workspace_id
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list")
|
reverse("api:enterprise:audit_log:list")
|
||||||
+ "?workspace_id="
|
+ "?workspace_id="
|
||||||
+ str(workspace_1.id),
|
+ str(workspace_1.id),
|
||||||
format="json",
|
format="json",
|
||||||
|
@ -480,7 +468,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
|
||||||
|
|
||||||
# by action_type
|
# by action_type
|
||||||
response = api_client.get(
|
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",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list")
|
reverse("api:enterprise:audit_log:list") + "?action_type=create_application",
|
||||||
+ "?action_type=create_application",
|
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# from timestamp
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list")
|
reverse("api:enterprise:audit_log:list")
|
||||||
+ "?from_timestamp=2023-01-01T12:00:01Z",
|
+ "?from_timestamp=2023-01-01T12:00:01Z",
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# to timestamp
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list")
|
reverse("api:enterprise:audit_log:list") + "?to_timestamp=2023-01-01T12:00:00Z",
|
||||||
+ "?to_timestamp=2023-01-01T12:00:00Z",
|
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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
|
# an invalid value in the query params should return a 400
|
||||||
response = api_client.get(
|
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",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
||||||
)
|
)
|
||||||
|
@ -576,7 +562,7 @@ def test_audit_log_can_export_to_csv_all_entries(
|
||||||
execute=True
|
execute=True
|
||||||
):
|
):
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
reverse("api:enterprise:admin:audit_log:export"),
|
reverse("api:enterprise:audit_log:async_export"),
|
||||||
data=csv_settings,
|
data=csv_settings,
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
||||||
|
@ -631,6 +617,7 @@ def test_audit_log_can_export_to_csv_filtered_entries(
|
||||||
"csv_column_separator": "|",
|
"csv_column_separator": "|",
|
||||||
"csv_first_row_header": False,
|
"csv_first_row_header": False,
|
||||||
"export_charset": "utf-8",
|
"export_charset": "utf-8",
|
||||||
|
"exclude_columns": "ip_address",
|
||||||
}
|
}
|
||||||
filters = {
|
filters = {
|
||||||
"filter_user_id": admin_user.id,
|
"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
|
# if the action type is invalid, it should return a 400
|
||||||
response = api_client.post(
|
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"},
|
data={**csv_settings, "filter_action_type": "wrong_type"},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
||||||
|
@ -653,7 +640,7 @@ def test_audit_log_can_export_to_csv_filtered_entries(
|
||||||
execute=True
|
execute=True
|
||||||
):
|
):
|
||||||
response = api_client.post(
|
response = api_client.post(
|
||||||
reverse("api:enterprise:admin:audit_log:export"),
|
reverse("api:enterprise:audit_log:async_export"),
|
||||||
data={**csv_settings, **filters},
|
data={**csv_settings, **filters},
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {admin_token}",
|
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)
|
action_type_registry.unregister(TemporaryActionType.type)
|
||||||
|
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list"),
|
reverse("api:enterprise:audit_log:list"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
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())
|
action_type_registry.register(TemporaryActionTypeV2())
|
||||||
|
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list"),
|
reverse("api:enterprise:audit_log:list"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
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
|
assert AuditLogEntry.objects.count() == 2
|
||||||
|
|
||||||
response = api_client.get(
|
response = api_client.get(
|
||||||
reverse("api:enterprise:admin:audit_log:list"),
|
reverse("api:enterprise:audit_log:list"),
|
||||||
format="json",
|
format="json",
|
||||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||||
)
|
)
|
|
@ -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"
|
|
@ -9,7 +9,7 @@ import pytest
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
|
|
||||||
from baserow.contrib.database.export.handler import ExportHandler
|
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.constants import JOB_FINISHED
|
||||||
from baserow.core.jobs.handler import JobHandler
|
from baserow.core.jobs.handler import JobHandler
|
||||||
from baserow_enterprise.audit_log.job_types import AuditLogExportJobType
|
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")
|
datetime.strptime("2023-01-01 12:00:08", "%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
assert job_type.get_filtered_queryset(job).count() == 0
|
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()
|
||||||
|
|
|
@ -11,25 +11,11 @@ from baserow_enterprise.audit_log.handler import AuditLogHandler
|
||||||
from baserow_enterprise.audit_log.models import AuditLogEntry
|
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
|
@pytest.mark.django_db
|
||||||
@override_settings(DEBUG=True)
|
@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
|
api_client, enterprise_data_fixture, synced_roles
|
||||||
):
|
):
|
||||||
enterprise_data_fixture.enable_enterprise()
|
|
||||||
user = enterprise_data_fixture.create_user()
|
user = enterprise_data_fixture.create_user()
|
||||||
|
|
||||||
with freeze_time("2023-01-01 12:00:00"):
|
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
|
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
|
@pytest.mark.django_db
|
||||||
@override_settings(DEBUG=True)
|
@override_settings(DEBUG=True)
|
||||||
|
|
|
@ -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 {
|
.audit-log__exported-list {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
border-top: 1px solid $color-neutral-200;
|
border-top: 1px solid $color-neutral-200;
|
||||||
|
|
|
@ -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>
|
|
@ -18,11 +18,7 @@
|
||||||
<div v-if="job" class="audit-log__exported-list-item">
|
<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-info">
|
||||||
<div class="audit-log__exported-list-item-name">
|
<div class="audit-log__exported-list-item-name">
|
||||||
{{
|
{{ getExportedFilenameTitle(job) }}
|
||||||
$t('auditLogExportModal.exportFilename', {
|
|
||||||
date: localDate(job.created_on),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="audit-log__exported-list-item-details">
|
<div class="audit-log__exported-list-item-details">
|
||||||
{{ humanExportedAt(job.created_on) }}
|
{{ humanExportedAt(job.created_on) }}
|
||||||
|
@ -37,11 +33,7 @@
|
||||||
>
|
>
|
||||||
<div class="audit-log__exported-list-item-info">
|
<div class="audit-log__exported-list-item-info">
|
||||||
<div class="audit-log__exported-list-item-name">
|
<div class="audit-log__exported-list-item-name">
|
||||||
{{
|
{{ getExportedFilenameTitle(finishedJob) }}
|
||||||
$t('auditLogExportModal.exportFilename', {
|
|
||||||
date: localDate(finishedJob.created_on),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="audit-log__exported-list-item-details">
|
<div class="audit-log__exported-list-item-details">
|
||||||
{{ humanExportedAt(finishedJob.created_on) }}
|
{{ humanExportedAt(finishedJob.created_on) }}
|
||||||
|
@ -67,8 +59,8 @@ import error from '@baserow/modules/core/mixins/error'
|
||||||
import moment from '@baserow/modules/core/moment'
|
import moment from '@baserow/modules/core/moment'
|
||||||
import { getHumanPeriodAgoCount } from '@baserow/modules/core/utils/date'
|
import { getHumanPeriodAgoCount } from '@baserow/modules/core/utils/date'
|
||||||
import ExportLoadingBar from '@baserow/modules/database/components/export/ExportLoadingBar'
|
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 AuditLogExportForm from '@baserow_enterprise/components/admin/forms/AuditLogExportForm'
|
||||||
|
import AuditLogAdminService from '@baserow_enterprise/services/auditLog'
|
||||||
|
|
||||||
const MAX_EXPORT_FILES = 4
|
const MAX_EXPORT_FILES = 4
|
||||||
|
|
||||||
|
@ -81,6 +73,10 @@ export default {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
workspaceId: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -96,8 +92,13 @@ export default {
|
||||||
const jobs = await AuditLogAdminService(this.$client).getLastExportJobs(
|
const jobs = await AuditLogAdminService(this.$client).getLastExportJobs(
|
||||||
MAX_EXPORT_FILES
|
MAX_EXPORT_FILES
|
||||||
)
|
)
|
||||||
this.lastFinishedJobs = jobs.filter((job) => job.state === 'finished')
|
const filteredJobs = this.workspaceId
|
||||||
const runningJob = jobs.find(
|
? 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)
|
(job) => !['failed', 'cancelled', 'finished'].includes(job.state)
|
||||||
)
|
)
|
||||||
this.job = runningJob || null
|
this.job = runningJob || null
|
||||||
|
@ -129,6 +130,18 @@ export default {
|
||||||
getExportedFilename(job) {
|
getExportedFilename(job) {
|
||||||
return job ? `audit_log_${job.created_on}.csv` : ''
|
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) {
|
humanExportedAt(timestamp) {
|
||||||
const { period, count } = getHumanPeriodAgoCount(timestamp)
|
const { period, count } = getHumanPeriodAgoCount(timestamp)
|
||||||
return this.$tc(`datetime.${period}Ago`, count)
|
return this.$tc(`datetime.${period}Ago`, count)
|
||||||
|
@ -154,11 +167,18 @@ export default {
|
||||||
value,
|
value,
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
if (this.workspaceId) {
|
||||||
|
filters.filter_workspace_id = this.workspaceId
|
||||||
|
filters.exclude_columns = 'workspace_id,workspace_name'
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await AuditLogAdminService(
|
const { data } = await AuditLogAdminService(
|
||||||
this.$client
|
this.$client
|
||||||
).startExportCsvJob({ ...values, ...filters })
|
).startExportCsvJob({
|
||||||
|
...values,
|
||||||
|
...filters,
|
||||||
|
})
|
||||||
this.lastFinishedJobs = this.lastFinishedJobs.slice(
|
this.lastFinishedJobs = this.lastFinishedJobs.slice(
|
||||||
0,
|
0,
|
||||||
MAX_EXPORT_FILES - 1
|
MAX_EXPORT_FILES - 1
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"sidebarTooltip": "Your account has access to the enterprise features globally",
|
"sidebarTooltip": "Your account has access to the enterprise features globally",
|
||||||
"rbac": "RBAC",
|
"rbac": "RBAC",
|
||||||
"sso": "SSO",
|
"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.",
|
"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."
|
"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"
|
"Authentication": "Authentication"
|
||||||
},
|
},
|
||||||
"auditLog": {
|
"auditLog": {
|
||||||
"title": "Audit log",
|
"adminTitle": "Audit log",
|
||||||
|
"workspaceTitle": "Audit log - {workspaceName}",
|
||||||
"filterUserTitle": "User",
|
"filterUserTitle": "User",
|
||||||
"filterWorkspaceTitle": "Workspace",
|
"filterWorkspaceTitle": "Workspace",
|
||||||
"filterActionTypeTitle": "Event Type",
|
"filterActionTypeTitle": "Event Type",
|
||||||
|
@ -84,7 +85,8 @@
|
||||||
},
|
},
|
||||||
"auditLogExportModal": {
|
"auditLogExportModal": {
|
||||||
"title": "Export to CSV",
|
"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",
|
"cancelledTitle": "Export failed",
|
||||||
"cancelledDescription": "Something went wrong while exporting the audit log. Please try again."
|
"cancelledDescription": "Something went wrong while exporting the audit log. Please try again."
|
||||||
},
|
},
|
||||||
|
@ -274,5 +276,9 @@
|
||||||
},
|
},
|
||||||
"snapshotModalWarning": {
|
"snapshotModalWarning": {
|
||||||
"message": "Please be aware that a snapshot will include any permissions set on the application and its tables."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<AuditLogExportModal
|
<AuditLogExportModal
|
||||||
ref="exportModal"
|
ref="exportModal"
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
|
:workspace-id="workspaceId"
|
||||||
></AuditLogExportModal>
|
></AuditLogExportModal>
|
||||||
<CrudTable
|
<CrudTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
|
@ -13,7 +14,11 @@
|
||||||
row-id-key="id"
|
row-id-key="id"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ $t('auditLog.title') }}
|
{{
|
||||||
|
workspaceId
|
||||||
|
? $t('auditLog.workspaceTitle', { workspaceName })
|
||||||
|
: $t('auditLog.adminTitle')
|
||||||
|
}}
|
||||||
</template>
|
</template>
|
||||||
<template #header-right-side>
|
<template #header-right-side>
|
||||||
<button
|
<button
|
||||||
|
@ -24,7 +29,10 @@
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #header-filters>
|
<template #header-filters>
|
||||||
<div class="audit-log__filters">
|
<div
|
||||||
|
class="audit-log__filters"
|
||||||
|
:class="{ 'audit-log__filters--workspace': workspaceId }"
|
||||||
|
>
|
||||||
<FilterWrapper :name="$t('auditLog.filterUserTitle')">
|
<FilterWrapper :name="$t('auditLog.filterUserTitle')">
|
||||||
<PaginatedDropdown
|
<PaginatedDropdown
|
||||||
ref="userFilter"
|
ref="userFilter"
|
||||||
|
@ -35,7 +43,10 @@
|
||||||
@input="filterUser"
|
@input="filterUser"
|
||||||
></PaginatedDropdown>
|
></PaginatedDropdown>
|
||||||
</FilterWrapper>
|
</FilterWrapper>
|
||||||
<FilterWrapper :name="$t('auditLog.filterWorkspaceTitle')">
|
<FilterWrapper
|
||||||
|
v-if="!workspaceId"
|
||||||
|
:name="$t('auditLog.filterWorkspaceTitle')"
|
||||||
|
>
|
||||||
<PaginatedDropdown
|
<PaginatedDropdown
|
||||||
ref="workspaceFilter"
|
ref="workspaceFilter"
|
||||||
:value="filters.workspace_id"
|
:value="filters.workspace_id"
|
||||||
|
@ -88,7 +99,7 @@ import _ from 'lodash'
|
||||||
import moment from '@baserow/modules/core/moment'
|
import moment from '@baserow/modules/core/moment'
|
||||||
import CrudTable from '@baserow/modules/core/components/crudTable/CrudTable'
|
import CrudTable from '@baserow/modules/core/components/crudTable/CrudTable'
|
||||||
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
|
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 DateFilter from '@baserow_enterprise/components/crudTable/filters/DateFilter'
|
||||||
import FilterWrapper from '@baserow_enterprise/components/crudTable/filters/FilterWrapper'
|
import FilterWrapper from '@baserow_enterprise/components/crudTable/filters/FilterWrapper'
|
||||||
import SimpleField from '@baserow/modules/core/components/crudTable/fields/SimpleField'
|
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 CrudTableColumn from '@baserow/modules/core/crudTable/crudTableColumn'
|
||||||
import LongTextField from '@baserow_enterprise/components/crudTable/fields/LongTextField'
|
import LongTextField from '@baserow_enterprise/components/crudTable/fields/LongTextField'
|
||||||
import AuditLogExportModal from '@baserow_enterprise/components/admin/modals/AuditLogExportModal'
|
import AuditLogExportModal from '@baserow_enterprise/components/admin/modals/AuditLogExportModal'
|
||||||
|
import EnterpriseFeatures from '@baserow_enterprise/features'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AuditLogAdminTable',
|
name: 'AuditLog',
|
||||||
components: {
|
components: {
|
||||||
AuditLogExportModal,
|
AuditLogExportModal,
|
||||||
CrudTable,
|
CrudTable,
|
||||||
|
@ -107,9 +119,40 @@ export default {
|
||||||
FilterWrapper,
|
FilterWrapper,
|
||||||
},
|
},
|
||||||
layout: 'app',
|
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() {
|
data() {
|
||||||
this.columns = [
|
const filters = {}
|
||||||
|
const params = this.$route.params
|
||||||
|
const workspaceId = params.workspaceId ? parseInt(params.workspaceId) : null
|
||||||
|
|
||||||
|
const columns = [
|
||||||
new CrudTableColumn(
|
new CrudTableColumn(
|
||||||
'user',
|
'user',
|
||||||
() => this.$t('auditLog.user'),
|
() => this.$t('auditLog.user'),
|
||||||
|
@ -120,6 +163,10 @@ export default {
|
||||||
{},
|
{},
|
||||||
'15'
|
'15'
|
||||||
),
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
columns.push(
|
||||||
new CrudTableColumn(
|
new CrudTableColumn(
|
||||||
'workspace',
|
'workspace',
|
||||||
() => this.$t('auditLog.workspace'),
|
() => this.$t('auditLog.workspace'),
|
||||||
|
@ -129,7 +176,14 @@ export default {
|
||||||
false,
|
false,
|
||||||
{},
|
{},
|
||||||
'15'
|
'15'
|
||||||
),
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
filters.workspace_id = workspaceId
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(
|
||||||
|
...[
|
||||||
new CrudTableColumn(
|
new CrudTableColumn(
|
||||||
'type',
|
'type',
|
||||||
() => this.$t('auditLog.actionType'),
|
() => this.$t('auditLog.actionType'),
|
||||||
|
@ -171,13 +225,23 @@ export default {
|
||||||
'10'
|
'10'
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
this.service = AuditLogAdminService(this.$client)
|
)
|
||||||
|
|
||||||
|
this.columns = columns
|
||||||
|
this.service = AuditLogService(this.$client)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters: {},
|
filters,
|
||||||
dateTimeFormat: 'YYYY-MM-DDTHH:mm:ss.SSSZ',
|
dateTimeFormat: 'YYYY-MM-DDTHH:mm:ss.SSSZ',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
workspaceName() {
|
||||||
|
const selectedWorkspace = this.$store.getters['workspace/get'](
|
||||||
|
this.workspaceId
|
||||||
|
)
|
||||||
|
return selectedWorkspace ? selectedWorkspace.name : ''
|
||||||
|
},
|
||||||
disableDates() {
|
disableDates() {
|
||||||
const minimumDate = moment('2023-01-01', 'YYYY-MM-DD')
|
const minimumDate = moment('2023-01-01', 'YYYY-MM-DD')
|
||||||
const maximumDate = moment().add(1, 'day').endOf('day')
|
const maximumDate = moment().add(1, 'day').endOf('day')
|
||||||
|
@ -186,6 +250,23 @@ export default {
|
||||||
from: maximumDate.toDate(),
|
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: {
|
methods: {
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
|
@ -196,7 +277,7 @@ export default {
|
||||||
'fromTimestampFilter',
|
'fromTimestampFilter',
|
||||||
'toTimestampFilter',
|
'toTimestampFilter',
|
||||||
]) {
|
]) {
|
||||||
this.$refs[filterRef].clear()
|
this.$refs[filterRef]?.clear()
|
||||||
}
|
}
|
||||||
this.filters = {}
|
this.filters = {}
|
||||||
},
|
},
|
||||||
|
@ -215,7 +296,7 @@ export default {
|
||||||
this.setFilter('user_id', userId)
|
this.setFilter('user_id', userId)
|
||||||
},
|
},
|
||||||
fetchUsers(page, search) {
|
fetchUsers(page, search) {
|
||||||
return this.service.fetchUsers(page, search)
|
return this.service.fetchUsers(page, search, this.workspaceId)
|
||||||
},
|
},
|
||||||
filterWorkspace(workspaceId) {
|
filterWorkspace(workspaceId) {
|
||||||
this.setFilter('workspace_id', workspaceId)
|
this.setFilter('workspace_id', workspaceId)
|
||||||
|
@ -224,7 +305,7 @@ export default {
|
||||||
return this.service.fetchWorkspaces(page, search)
|
return this.service.fetchWorkspaces(page, search)
|
||||||
},
|
},
|
||||||
fetchActionTypes(page, search) {
|
fetchActionTypes(page, search) {
|
||||||
return this.service.fetchActionTypes(page, search)
|
return this.service.fetchActionTypes(page, search, this.workspaceId)
|
||||||
},
|
},
|
||||||
filterActionType(actionTypeId) {
|
filterActionType(actionTypeId) {
|
||||||
this.setFilter('action_type', actionTypeId)
|
this.setFilter('action_type', actionTypeId)
|
|
@ -1,5 +1,6 @@
|
||||||
import { BaserowPlugin } from '@baserow/modules/core/plugins'
|
import { BaserowPlugin } from '@baserow/modules/core/plugins'
|
||||||
import ChatwootSupportSidebarWorkspace from '@baserow_enterprise/components/ChatwootSupportSidebarWorkspace'
|
import ChatwootSupportSidebarWorkspace from '@baserow_enterprise/components/ChatwootSupportSidebarWorkspace'
|
||||||
|
import AuditLogSidebarWorkspace from '@baserow_enterprise/components/AuditLogSidebarWorkspace'
|
||||||
import MemberRolesDatabaseContextItem from '@baserow_enterprise/components/member-roles/MemberRolesDatabaseContextItem'
|
import MemberRolesDatabaseContextItem from '@baserow_enterprise/components/member-roles/MemberRolesDatabaseContextItem'
|
||||||
import MemberRolesTableContextItem from '@baserow_enterprise/components/member-roles/MemberRolesTableContextItem'
|
import MemberRolesTableContextItem from '@baserow_enterprise/components/member-roles/MemberRolesTableContextItem'
|
||||||
import EnterpriseFeatures from '@baserow_enterprise/features'
|
import EnterpriseFeatures from '@baserow_enterprise/features'
|
||||||
|
@ -10,12 +11,17 @@ export class EnterprisePlugin extends BaserowPlugin {
|
||||||
return 'enterprise'
|
return 'enterprise'
|
||||||
}
|
}
|
||||||
|
|
||||||
getSidebarWorkspaceComponent(workspace) {
|
getSidebarWorkspaceComponents(workspace) {
|
||||||
const supportEnabled = this.app.$hasFeature(
|
const supportEnabled = this.app.$hasFeature(
|
||||||
EnterpriseFeatures.SUPPORT,
|
EnterpriseFeatures.SUPPORT,
|
||||||
workspace.id
|
workspace.id
|
||||||
)
|
)
|
||||||
return supportEnabled ? ChatwootSupportSidebarWorkspace : null
|
const sidebarItems = []
|
||||||
|
if (supportEnabled) {
|
||||||
|
sidebarItems.push(ChatwootSupportSidebarWorkspace)
|
||||||
|
}
|
||||||
|
sidebarItems.push(AuditLogSidebarWorkspace)
|
||||||
|
return sidebarItems
|
||||||
}
|
}
|
||||||
|
|
||||||
getAdditionalDatabaseContextComponents(workspace, database) {
|
getAdditionalDatabaseContextComponents(workspace, database) {
|
||||||
|
|
|
@ -19,6 +19,11 @@ export const routes = [
|
||||||
{
|
{
|
||||||
name: 'admin-audit-log',
|
name: 'admin-audit-log',
|
||||||
path: '/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'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
@ -2,36 +2,44 @@ import baseService from '@baserow/modules/core/crudTable/baseService'
|
||||||
import jobService from '@baserow/modules/core/services/job'
|
import jobService from '@baserow/modules/core/services/job'
|
||||||
|
|
||||||
export default (client) => {
|
export default (client) => {
|
||||||
return Object.assign(baseService(client, '/admin/audit-log/'), {
|
return Object.assign(baseService(client, `/audit-log/`), {
|
||||||
fetchUsers(page, search) {
|
fetchUsers(page, search, workspaceId = null) {
|
||||||
const usersUrl = '/admin/audit-log/users/'
|
const usersUrl = `/audit-log/users/`
|
||||||
const userPaginatedService = baseService(client, usersUrl)
|
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) {
|
fetchWorkspaces(page, search) {
|
||||||
const workspacesUrl = '/admin/audit-log/workspaces/'
|
const workspacesUrl = `/audit-log/workspaces/`
|
||||||
const workspacePaginatedService = baseService(client, workspacesUrl)
|
const workspacePaginatedService = baseService(client, workspacesUrl)
|
||||||
return workspacePaginatedService.fetch(
|
return workspacePaginatedService.fetch(
|
||||||
workspacesUrl,
|
workspacesUrl,
|
||||||
page,
|
page,
|
||||||
search,
|
search,
|
||||||
[],
|
[],
|
||||||
[]
|
{}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
fetchActionTypes(page, search) {
|
fetchActionTypes(page, search, workspaceId = null) {
|
||||||
const actionTypesUrl = '/admin/audit-log/action-types/'
|
const actionTypesUrl = `/audit-log/action-types/`
|
||||||
const actionTypePaginatedService = baseService(client, actionTypesUrl)
|
const actionTypePaginatedService = baseService(client, actionTypesUrl)
|
||||||
|
const filters = {}
|
||||||
|
if (workspaceId) {
|
||||||
|
filters.workspace_id = workspaceId
|
||||||
|
}
|
||||||
return actionTypePaginatedService.fetch(
|
return actionTypePaginatedService.fetch(
|
||||||
actionTypesUrl,
|
actionTypesUrl,
|
||||||
page,
|
page,
|
||||||
search,
|
search,
|
||||||
[],
|
[],
|
||||||
[]
|
filters
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
startExportCsvJob(data) {
|
startExportCsvJob(data) {
|
||||||
return client.post('/admin/audit-log/export/', data)
|
return client.post(`/audit-log/export/`, data)
|
||||||
},
|
},
|
||||||
getExportJobInfo(jobId) {
|
getExportJobInfo(jobId) {
|
||||||
return jobService(client).get(jobId)
|
return jobService(client).get(jobId)
|
|
@ -23,10 +23,9 @@ from baserow.api.pagination import PageNumberPagination
|
||||||
from baserow.api.schemas import get_error_schema
|
from baserow.api.schemas import get_error_schema
|
||||||
|
|
||||||
|
|
||||||
class AdminListingView(
|
class APIListingView(
|
||||||
APIView, SearchableViewMixin, SortableViewMixin, FilterableViewMixin
|
APIView, SearchableViewMixin, SortableViewMixin, FilterableViewMixin
|
||||||
):
|
):
|
||||||
permission_classes = (IsAdminUser,)
|
|
||||||
serializer_class = None
|
serializer_class = None
|
||||||
search_fields: List[str] = ["id"]
|
search_fields: List[str] = ["id"]
|
||||||
filters_field_mapping: Dict[str, str] = {}
|
filters_field_mapping: Dict[str, str] = {}
|
||||||
|
@ -72,13 +71,26 @@ class AdminListingView(
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_extend_schema_parameters(
|
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
|
Returns the schema properties that can be used in in the @extend_schema
|
||||||
decorator.
|
decorator.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
parameters = []
|
||||||
|
if search_fields:
|
||||||
|
parameters.append(
|
||||||
|
OpenApiParameter(
|
||||||
|
name="search",
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
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()
|
fields = sort_field_mapping.keys()
|
||||||
all_fields = ", ".join(fields)
|
all_fields = ", ".join(fields)
|
||||||
field_name_1 = "field_1"
|
field_name_1 = "field_1"
|
||||||
|
@ -89,15 +101,7 @@ class AdminListingView(
|
||||||
if i == 1:
|
if i == 1:
|
||||||
field_name_2 = field
|
field_name_2 = field
|
||||||
|
|
||||||
return {
|
parameters.append(
|
||||||
"parameters": [
|
|
||||||
OpenApiParameter(
|
|
||||||
name="search",
|
|
||||||
location=OpenApiParameter.QUERY,
|
|
||||||
type=OpenApiTypes.STR,
|
|
||||||
description=f"If provided only {name} that match the query will "
|
|
||||||
f"be returned.",
|
|
||||||
),
|
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name="sorts",
|
name="sorts",
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
|
@ -105,13 +109,18 @@ class AdminListingView(
|
||||||
description=f"A comma separated string of attributes to sort by, "
|
description=f"A comma separated string of attributes to sort by, "
|
||||||
f"each attribute must be prefixed with `+` for a descending "
|
f"each attribute must be prefixed with `+` for a descending "
|
||||||
f"sort or a `-` for an ascending sort. The accepted attribute "
|
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_2}` will sort the {name} first by descending "
|
||||||
f"{field_name_1} and then ascending {field_name_2}. A sort"
|
f"{field_name_1} and then ascending {field_name_2}. A sort"
|
||||||
f"parameter with multiple instances of the same sort attribute "
|
f"parameter with multiple instances of the same sort attribute "
|
||||||
f"will respond with the ERROR_INVALID_SORT_ATTRIBUTE "
|
f"will respond with the ERROR_INVALID_SORT_ATTRIBUTE "
|
||||||
f"error.",
|
f"error.",
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"parameters": [
|
||||||
|
*parameters,
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name="page",
|
name="page",
|
||||||
location=OpenApiParameter.QUERY,
|
location=OpenApiParameter.QUERY,
|
||||||
|
@ -125,6 +134,7 @@ class AdminListingView(
|
||||||
description=f"Defines how many {name} should be returned per "
|
description=f"Defines how many {name} should be returned per "
|
||||||
f"page.",
|
f"page.",
|
||||||
),
|
),
|
||||||
|
*(extra_parameters or []),
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
200: serializer_class(many=True),
|
200: serializer_class(many=True),
|
||||||
|
@ -139,3 +149,7 @@ class AdminListingView(
|
||||||
401: None,
|
401: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminListingView(APIListingView):
|
||||||
|
permission_classes = (IsAdminUser,)
|
||||||
|
|
|
@ -35,6 +35,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table__title {
|
.data-table__title {
|
||||||
|
@extend %ellipsis;
|
||||||
|
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -448,8 +448,8 @@ export default {
|
||||||
},
|
},
|
||||||
sidebarWorkspaceComponents() {
|
sidebarWorkspaceComponents() {
|
||||||
return Object.values(this.$registry.getAll('plugin'))
|
return Object.values(this.$registry.getAll('plugin'))
|
||||||
.map((plugin) =>
|
.flatMap((plugin) =>
|
||||||
plugin.getSidebarWorkspaceComponent(this.selectedWorkspace)
|
plugin.getSidebarWorkspaceComponents(this.selectedWorkspace)
|
||||||
)
|
)
|
||||||
.filter((component) => component !== null)
|
.filter((component) => component !== null)
|
||||||
},
|
},
|
||||||
|
|
|
@ -144,6 +144,7 @@
|
||||||
"workspaceContext": {
|
"workspaceContext": {
|
||||||
"renameWorkspace": "Rename workspace",
|
"renameWorkspace": "Rename workspace",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
|
"auditLog": "Audit log",
|
||||||
"viewTrash": "View trash",
|
"viewTrash": "View trash",
|
||||||
"leaveWorkspace": "Leave workspace",
|
"leaveWorkspace": "Leave workspace",
|
||||||
"deleteWorkspace": "Delete workspace"
|
"deleteWorkspace": "Delete workspace"
|
||||||
|
|
|
@ -36,7 +36,7 @@ export class BaserowPlugin extends Registerable {
|
||||||
* Every registered plugin can display an additional item in the sidebar within
|
* Every registered plugin can display an additional item in the sidebar within
|
||||||
* the workspace context.
|
* the workspace context.
|
||||||
*/
|
*/
|
||||||
getSidebarWorkspaceComponent(workspace) {
|
getSidebarWorkspaceComponents(workspace) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ export default {
|
||||||
beforeRouteLeave(to, from, next) {
|
beforeRouteLeave(to, from, next) {
|
||||||
this.$store.dispatch('view/unselect')
|
this.$store.dispatch('view/unselect')
|
||||||
this.$store.dispatch('table/unselect')
|
this.$store.dispatch('table/unselect')
|
||||||
|
this.$store.dispatch('application/unselect')
|
||||||
next()
|
next()
|
||||||
},
|
},
|
||||||
async beforeRouteUpdate(to, from, next) {
|
async beforeRouteUpdate(to, from, next) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue