1
0
Fork 0
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:
Davide Silvestri 2023-08-25 15:31:05 +00:00
parent 96c0ca83a0
commit 35a535e48a
35 changed files with 1315 additions and 490 deletions
changelog/entries/unreleased/feature
enterprise
premium/backend/src/baserow_premium/api/admin
web-frontend/modules
core
assets/scss/components
components/sidebar
locales
plugins.js
database/pages

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Introduce Workspace level audit log feature",
"issue_number": 1901,
"bullet_points": [],
"created_at": "2023-08-09"
}

View file

@ -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"),
] ]

View file

@ -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)

View file

@ -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)

View 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",
),
]

View 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)

View file

@ -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")),
] ]

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -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.",
)

View file

@ -0,0 +1,5 @@
from baserow.core.operations import WorkspaceCoreOperationType
class ListWorkspaceAuditLogEntriesOperationType(WorkspaceCoreOperationType):
type = "workspace.list_audit_log_entries"

View file

@ -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

View file

@ -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),
),
]

View file

@ -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,
] ]
) )

View file

@ -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}",
) )

View file

@ -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"

View file

@ -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()

View file

@ -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)

View file

@ -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;

View file

@ -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>

View file

@ -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

View file

@ -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"
} }
} }

View file

@ -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)

View file

@ -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) {

View file

@ -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'),
}, },
] ]

View file

@ -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)

View file

@ -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,)

View file

@ -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;

View file

@ -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)
}, },

View file

@ -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"

View file

@ -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
} }

View file

@ -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) {