1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-03 04:35:31 +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 .views import (
AdminAuditLogActionTypeFilterView,
AdminAuditLogUserFilterView,
AdminAuditLogView,
AdminAuditLogWorkspaceFilterView,
from baserow_enterprise.api.audit_log.views import (
AsyncAuditLogExportView,
AuditLogActionTypeFilterView,
AuditLogUserFilterView,
AuditLogView,
AuditLogWorkspaceFilterView,
)
app_name = "baserow_enterprise.api.audit_log"
urlpatterns = [
re_path(r"^$", AdminAuditLogView.as_view(), name="list"),
re_path(r"users/$", AdminAuditLogUserFilterView.as_view(), name="users"),
re_path(r"^$", AuditLogView.as_view(), name="list"),
re_path(r"users/$", AuditLogUserFilterView.as_view(), name="users"),
re_path(r"workspaces/$", AuditLogWorkspaceFilterView.as_view(), name="workspaces"),
re_path(
r"workspaces/$", AdminAuditLogWorkspaceFilterView.as_view(), name="workspaces"
),
# GroupDeprecation
re_path(
r"action-types/$",
AdminAuditLogActionTypeFilterView.as_view(),
name="action_types",
r"action-types/$", AuditLogActionTypeFilterView.as_view(), name="action_types"
),
re_path(r"export/$", AsyncAuditLogExportView.as_view(), name="export"),
]

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.utils import translation
from django.utils.functional import lazy
from drf_spectacular.types import OpenApiTypes
@ -26,6 +27,25 @@ def render_action_type(action_type):
return action_type_registry.get(action_type).get_short_description()
class AuditLogQueryParamsSerializer(serializers.Serializer):
page = serializers.IntegerField(required=False, default=1)
search = serializers.CharField(required=False, default=None)
sorts = serializers.CharField(required=False, default=None)
user_id = serializers.IntegerField(min_value=1, required=False, default=None)
workspace_id = serializers.IntegerField(min_value=1, required=False, default=None)
action_type = serializers.ChoiceField(
choices=lazy(action_type_registry.get_types, list)(),
default=None,
required=False,
)
from_timestamp = serializers.DateTimeField(required=False, default=None)
to_timestamp = serializers.DateTimeField(required=False, default=None)
class AuditLogWorkspaceFilterQueryParamsSerializer(serializers.Serializer):
workspace_id = serializers.IntegerField(min_value=1, required=False, default=None)
class AuditLogSerializer(serializers.ModelSerializer):
user = serializers.SerializerMethodField()
group = serializers.SerializerMethodField() # GroupDeprecation
@ -34,14 +54,6 @@ class AuditLogSerializer(serializers.ModelSerializer):
description = serializers.SerializerMethodField()
timestamp = serializers.DateTimeField(source="action_timestamp")
@extend_schema_field(OpenApiTypes.STR)
def get_group(self, instance): # GroupDeprecation
return self.get_workspace(instance)
@extend_schema_field(OpenApiTypes.STR)
def get_workspace(self, instance):
return render_workspace(instance.workspace_id, instance.workspace_name)
@extend_schema_field(OpenApiTypes.STR)
def get_user(self, instance):
return render_user(instance.user_id, instance.user_email)
@ -54,6 +66,14 @@ class AuditLogSerializer(serializers.ModelSerializer):
def get_description(self, instance):
return instance.description
@extend_schema_field(OpenApiTypes.STR)
def get_group(self, instance): # GroupDeprecation
return self.get_workspace(instance)
@extend_schema_field(OpenApiTypes.STR)
def get_workspace(self, instance):
return render_workspace(instance.workspace_id, instance.workspace_name)
class Meta:
model = AuditLogEntry
fields = (
@ -98,6 +118,42 @@ class AuditLogActionTypeSerializer(serializers.Serializer):
return render_action_type(instance.type)
def serialize_filtered_action_types(user, search=None, exclude_types=None):
exclude_types = exclude_types or []
def filter_action_types(action_types, search):
search_lower = search.lower()
return [
action_type
for action_type in action_types
if search_lower in action_type["value"].lower()
]
# Since action's type is translated at runtime and there aren't that
# many, we can fetch them all and filter them in memory to match the
# search query on the translated value.
with translation.override(user.profile.language):
filtered_action_types = [
action_type
for action_type in action_type_registry.get_all()
if action_type.type not in exclude_types
]
action_types = AuditLogActionTypeSerializer(
filtered_action_types, many=True
).data
if search:
action_types = filter_action_types(action_types, search)
return {
"count": len(action_types),
"next": None,
"previous": None,
"results": sorted(action_types, key=lambda x: x["value"]),
}
AuditLogExportJobRequestSerializer = job_type_registry.get(
AuditLogExportJobType.type
).get_serializer_class(
@ -112,18 +168,3 @@ AuditLogExportJobResponseSerializer = job_type_registry.get(
base_class=serializers.Serializer,
meta_ref_name="SingleAuditLogExportJobResponseSerializer",
)
class AuditLogQueryParamsSerializer(serializers.Serializer):
page = serializers.IntegerField(required=False, default=1)
search = serializers.CharField(required=False, default=None)
sorts = serializers.CharField(required=False, default=None)
user_id = serializers.IntegerField(min_value=0, required=False, default=None)
workspace_id = serializers.IntegerField(min_value=0, required=False, default=None)
action_type = serializers.ChoiceField(
choices=lazy(action_type_registry.get_types, list)(),
default=None,
required=False,
)
from_timestamp = serializers.DateTimeField(required=False, default=None)
to_timestamp = serializers.DateTimeField(required=False, default=None)

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 .admin import urls as admin_urls
from .audit_log import urls as audit_log_urls
from .role import urls as role_urls
from .sso import urls as sso_urls
from .teams import urls as teams_urls
@ -12,4 +13,5 @@ urlpatterns = [
path("role/", include(role_urls, namespace="role")),
path("admin/", include(admin_urls, namespace="admin")),
path("sso/", include(sso_urls, namespace="sso")),
path("audit-log/", include(audit_log_urls, namespace="audit_log")),
]

View file

@ -10,6 +10,9 @@ class BaserowEnterpriseConfig(AppConfig):
def ready(self):
from baserow.core.jobs.registries import job_type_registry
from baserow_enterprise.audit_log.job_types import AuditLogExportJobType
from baserow_enterprise.audit_log.operations import (
ListWorkspaceAuditLogEntriesOperationType,
)
job_type_registry.register(AuditLogExportJobType())
@ -101,6 +104,7 @@ class BaserowEnterpriseConfig(AppConfig):
operation_type_registry.register(UpdateRoleApplicationOperationType())
operation_type_registry.register(ReadRoleTableOperationType())
operation_type_registry.register(UpdateRoleTableOperationType())
operation_type_registry.register(ListWorkspaceAuditLogEntriesOperationType())
from baserow.core.registries import subject_type_registry

View file

@ -3,13 +3,10 @@ from typing import Any, Dict, Optional, Type
from django.contrib.auth.models import AbstractUser
from baserow_premium.license.handler import LicenseHandler
from baserow.api.sessions import get_user_remote_addr_ip
from baserow.core.action.registries import ActionType
from baserow.core.action.signals import ActionCommandType
from baserow.core.models import Workspace
from baserow_enterprise.features import AUDIT_LOG
from .models import AuditLogEntry
@ -44,12 +41,8 @@ class AuditLogHandler:
is sent so it can be used to identify other resources created at the
same time (i.e. row_history entries).
:param workspace: The workspace that the action was performed on.
:raises FeaturesNotAvailableError: When the AUDIT_LOG feature is not
available.
"""
LicenseHandler.raise_if_user_doesnt_have_feature_instance_wide(AUDIT_LOG, user)
workspace_id, workspace_name = None, None
if workspace is not None:
workspace_id = workspace.id

View file

@ -30,6 +30,61 @@ from baserow_enterprise.features import AUDIT_LOG
from .models import AuditLogEntry, AuditLogExportJob
AUDIT_LOG_CSV_COLUMN_NAMES = OrderedDict(
{
"user_email": {
"field": "user_email",
"descr": _("User Email"),
},
"user_id": {
"field": "user_id",
"descr": _("User ID"),
},
"workspace_name": {
"field": "workspace_name",
"descr": _("Group Name"),
},
"workspace_id": {
"field": "workspace_id",
"descr": _("Group ID"),
},
"type": {
"field": "type",
"descr": _("Action Type"),
},
"description": {
"field": "description",
"descr": _("Description"),
},
"timestamp": {
"field": "action_timestamp",
"descr": _("Timestamp"),
},
"ip_address": {
"field": "ip_address",
"descr": _("IP Address"),
},
}
)
class CommaSeparatedCsvColumnsField(serializers.CharField):
def validate_values(self, value):
items = value.split(",")
if len(set(items)) != len(items):
raise serializers.ValidationError("Duplicate items are not allowed.")
if len(items) > 0:
for item in items:
if item not in AUDIT_LOG_CSV_COLUMN_NAMES.keys():
raise serializers.ValidationError(f"{item} is not a valid choice.")
if len(items) == len(self.child.choices):
raise serializers.ValidationError("At least one column must be included.")
return value
class AuditLogExportJobType(JobType):
type = "audit_log_export"
@ -46,24 +101,16 @@ class AuditLogExportJobType(JobType):
"filter_action_type",
"filter_from_timestamp",
"filter_to_timestamp",
"exclude_columns",
]
serializer_field_names = [
"csv_column_separator",
"csv_first_row_header",
"export_charset",
"filter_user_id",
"filter_workspace_id",
"filter_action_type",
"filter_from_timestamp",
"filter_to_timestamp",
*request_serializer_field_names,
"created_on",
"exported_file_name",
"url",
]
serializer_field_overrides = {
# Map to the python encoding aliases at the same time by using a
# DisplayChoiceField
base_serializer_field_overrides = {
"export_charset": DisplayChoiceField(
choices=SUPPORTED_EXPORT_CHARSETS,
default="utf-8",
@ -106,10 +153,32 @@ class AuditLogExportJobType(JobType):
required=False,
help_text="Optional: The end date to filter the audit log by.",
),
"exclude_columns": CommaSeparatedCsvColumnsField(
required=False,
help_text=(
"Optional: A comma separated list of column names to exclude from the export. "
f"Available options are `{', '.join(AUDIT_LOG_CSV_COLUMN_NAMES.keys())}`."
),
),
}
request_serializer_field_overrides = {
**base_serializer_field_overrides,
}
serializer_field_overrides = {
# Map to the python encoding aliases at the same time by using a
# DisplayChoiceField
**base_serializer_field_overrides,
"created_on": serializers.DateTimeField(
read_only=True,
help_text="The date and time when the export job was created.",
),
"exported_file_name": serializers.CharField(
read_only=True,
help_text="The name of the file that was created by the export job.",
),
"url": serializers.SerializerMethodField(
help_text="The URL to download the exported file.",
),
}
def before_delete(self, job):
@ -135,18 +204,12 @@ class AuditLogExportJobType(JobType):
if job.export_charset == "utf-8":
file.write(b"\xef\xbb\xbf")
field_header_mapping = OrderedDict(
{
"user_email": _("User Email"),
"user_id": _("User ID"),
"workspace_name": _("Group Name"), # GroupDeprecation
"workspace_id": _("Group ID"),
"type": _("Action Type"),
"description": _("Description"),
"action_timestamp": _("Timestamp"),
"ip_address": _("IP Address"),
}
)
exclude_columns = job.exclude_columns.split(",") if job.exclude_columns else []
field_header_mapping = {
k: v["descr"]
for (k, v) in AUDIT_LOG_CSV_COLUMN_NAMES.items()
if k not in exclude_columns
}
writer = csv.writer(
file,
@ -158,7 +221,11 @@ class AuditLogExportJobType(JobType):
if job.csv_first_row_header:
writer.writerow(field_header_mapping.values())
fields = field_header_mapping.keys()
fields = [
v["field"]
for (k, v) in AUDIT_LOG_CSV_COLUMN_NAMES.items()
if k not in exclude_columns
]
paginator = Paginator(queryset.all(), 2000)
export_progress = ChildProgressBuilder.build(
progress.create_child_builder(represents_progress=progress.total),

View file

@ -32,10 +32,10 @@ class AuditLogEntry(CreatedAndUpdatedOnMixin, models.Model):
REDO = ActionCommandType.UNDO.name, _("REDONE")
user_id = models.PositiveIntegerField(null=True)
user_email = models.CharField(max_length=150, null=True, blank=True)
user_email = models.EmailField(null=True, blank=True)
workspace_id = models.PositiveIntegerField(null=True)
workspace_name = models.CharField(max_length=160, null=True, blank=True)
workspace_name = models.CharField(max_length=165, null=True, blank=True)
action_uuid = models.CharField(max_length=36, null=True)
action_type = models.TextField()
@ -50,8 +50,8 @@ class AuditLogEntry(CreatedAndUpdatedOnMixin, models.Model):
# we don't want break the audit log in case an action is removed or changed.
# Storing the original description and type in the database we'll always be
# able to fallback to them and show the original string in case. NOTE: if
# the _('$original_description') has been removed from the codebase, the
# entry won't be translated anymore.
# also the _('$original_description') has been removed from the codebase,
# the entry won't be translated anymore.
original_action_short_descr = models.TextField(null=True, blank=True)
original_action_long_descr = models.TextField(null=True, blank=True)
original_action_context_descr = models.TextField(null=True, blank=True)
@ -147,3 +147,8 @@ class AuditLogExportJob(Job):
null=True,
help_text="The CSV file containing the filtered audit log entries.",
)
exclude_columns = models.CharField(
max_length=255,
null=True,
help_text="A comma separated list of column names to exclude from the export.",
)

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.dispatch import receiver
from baserow_premium.license.exceptions import FeaturesNotAvailableError
from baserow.core.action.registries import ActionType
from baserow.core.action.signals import ActionCommandType, action_done
from baserow.core.models import Workspace
@ -25,16 +23,13 @@ def log_action(
workspace: Optional[Workspace] = None,
**kwargs
):
try:
AuditLogHandler.log_action(
user,
action_type,
action_params,
action_timestamp,
action_command_type,
action_uuid=action_uuid,
workspace=workspace,
**kwargs
)
except FeaturesNotAvailableError:
pass
AuditLogHandler.log_action(
user,
action_type,
action_params,
action_timestamp,
action_command_type,
action_uuid=action_uuid,
workspace=workspace,
**kwargs
)

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,
ReadWorkspaceTrashOperationType,
)
from baserow_enterprise.audit_log.operations import (
ListWorkspaceAuditLogEntriesOperationType,
)
from baserow_enterprise.role.constants import (
ADMIN_ROLE_UID,
BUILDER_ROLE_UID,
@ -411,5 +414,6 @@ default_roles[ADMIN_ROLE_UID].extend(
ListSnapshotsApplicationOperationType,
DeleteApplicationSnapshotOperationType,
RestoreDomainOperationType,
ListWorkspaceAuditLogEntriesOperationType,
]
)

View file

@ -28,15 +28,23 @@ from baserow_enterprise.audit_log.models import AuditLogEntry
@pytest.mark.django_db
@pytest.mark.parametrize("url_name", ["users", "workspaces", "action_types", "list"])
@pytest.mark.parametrize(
"method,url_name",
[
("get", "users"),
("get", "action_types"),
("get", "list"),
("post", "async_export"),
],
)
@override_settings(DEBUG=True)
def test_admins_can_not_access_audit_log_endpoints_without_an_enterprise_license(
api_client, enterprise_data_fixture, url_name
def test_admins_cannot_access_audit_log_endpoints_without_an_enterprise_license(
api_client, enterprise_data_fixture, method, url_name
):
user, token = enterprise_data_fixture.create_user_and_token(is_staff=True)
_, token = enterprise_data_fixture.create_user_and_token(is_staff=True)
response = api_client.get(
reverse(f"api:enterprise:admin:audit_log:{url_name}"),
response = getattr(api_client, method)(
reverse(f"api:enterprise:audit_log:{url_name}"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -46,15 +54,15 @@ def test_admins_can_not_access_audit_log_endpoints_without_an_enterprise_license
@pytest.mark.django_db
@pytest.mark.parametrize("url_name", ["users", "workspaces", "action_types", "list"])
@override_settings(DEBUG=True)
def test_non_admins_can_not_access_audit_log_endpoints(
def test_non_admins_cannot_access_audit_log_endpoints(
api_client, enterprise_data_fixture, url_name
):
enterprise_data_fixture.enable_enterprise()
user, token = enterprise_data_fixture.create_user_and_token()
_, token = enterprise_data_fixture.create_user_and_token()
response = api_client.get(
reverse(f"api:enterprise:admin:audit_log:{url_name}"),
reverse(f"api:enterprise:audit_log:{url_name}"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -63,30 +71,13 @@ def test_non_admins_can_not_access_audit_log_endpoints(
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_admins_can_not_export_audit_log_to_csv_without_an_enterprise_license(
api_client, enterprise_data_fixture
):
user, token = enterprise_data_fixture.create_user_and_token(is_staff=True)
response = api_client.post(
reverse(f"api:enterprise:admin:audit_log:export"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_402_PAYMENT_REQUIRED
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_non_admins_can_not_export_audit_log_to_csv(
api_client, enterprise_data_fixture
):
def test_non_admins_cannot_export_audit_log_to_csv(api_client, enterprise_data_fixture):
enterprise_data_fixture.enable_enterprise()
user, token = enterprise_data_fixture.create_user_and_token()
response = api_client.post(
reverse(f"api:enterprise:admin:audit_log:export"),
reverse("api:enterprise:audit_log:async_export"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -108,7 +99,7 @@ def test_audit_log_user_filter_returns_users_correctly(
# no search query should return all users
response = api_client.get(
reverse("api:enterprise:admin:audit_log:users"),
reverse("api:enterprise:audit_log:users"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -125,7 +116,7 @@ def test_audit_log_user_filter_returns_users_correctly(
# searching by email should return only the correct user
response = api_client.get(
reverse("api:enterprise:admin:audit_log:users") + "?search=admin",
reverse("api:enterprise:audit_log:users") + "?search=admin",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -156,7 +147,7 @@ def test_audit_log_workspace_filter_returns_workspaces_correctly(
# no search query should return all workspaces
response = api_client.get(
reverse("api:enterprise:admin:audit_log:workspaces"),
reverse("api:enterprise:audit_log:workspaces"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -173,7 +164,7 @@ def test_audit_log_workspace_filter_returns_workspaces_correctly(
# searching by name should return only the correct workspace
response = api_client.get(
reverse("api:enterprise:admin:audit_log:workspaces") + "?search=1",
reverse("api:enterprise:audit_log:workspaces") + "?search=1",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -200,7 +191,7 @@ def test_audit_log_action_type_filter_returns_action_types_correctly(
# no search query should return all the available action types``
response = api_client.get(
reverse("api:enterprise:admin:audit_log:action_types"),
reverse("api:enterprise:audit_log:action_types"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -220,7 +211,7 @@ def test_audit_log_action_type_filter_returns_action_types_correctly(
# searching by name should return only the correct action_type
response = api_client.get(
reverse("api:enterprise:admin:audit_log:action_types")
reverse("api:enterprise:audit_log:action_types")
+ f"?search=create+application",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
@ -246,7 +237,7 @@ def test_audit_log_action_types_are_translated_in_the_admin_language(
with patch("django.utils.translation.override") as mock_override:
api_client.get(
reverse("api:enterprise:admin:audit_log:action_types"),
reverse("api:enterprise:audit_log:action_types"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -254,10 +245,7 @@ def test_audit_log_action_types_are_translated_in_the_admin_language(
# the search works in the user language
response = api_client.get(
(
reverse("api:enterprise:admin:audit_log:action_types")
+ f"?search=crea+progetto"
),
(reverse("api:enterprise:audit_log:action_types") + f"?search=crea+progetto"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -272,7 +260,7 @@ def test_audit_log_action_types_are_translated_in_the_admin_language(
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_audit_log_entries_are_not_created_without_a_license(
def test_audit_log_entries_are_created_even_without_a_license(
api_client, enterprise_data_fixture
):
user = enterprise_data_fixture.create_user()
@ -283,7 +271,7 @@ def test_audit_log_entries_are_not_created_without_a_license(
with freeze_time("2023-01-01 12:00:01"):
CreateWorkspaceActionType.do(user, "workspace 2")
assert AuditLogEntry.objects.count() == 0
assert AuditLogEntry.objects.count() == 2
@pytest.mark.django_db
@ -319,7 +307,7 @@ def test_audit_log_entries_are_created_from_actions_and_returned_in_order(
}
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list"),
reverse("api:enterprise:audit_log:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -366,14 +354,14 @@ def test_audit_log_entries_are_translated_in_the_user_language(
with patch("django.utils.translation.override") as mock_override:
api_client.get(
reverse("api:enterprise:admin:audit_log:list"),
reverse("api:enterprise:audit_log:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
mock_override.assert_called_once_with("it")
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list"),
reverse("api:enterprise:audit_log:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -450,7 +438,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
# by user_id
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list") + "?user_id=" + str(user.id),
reverse("api:enterprise:audit_log:list") + "?user_id=" + str(user.id),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -464,7 +452,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
# by workspace_id
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list")
reverse("api:enterprise:audit_log:list")
+ "?workspace_id="
+ str(workspace_1.id),
format="json",
@ -480,7 +468,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
# by action_type
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list") + "?action_type=create_group",
reverse("api:enterprise:audit_log:list") + "?action_type=create_group",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -493,8 +481,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
}
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list")
+ "?action_type=create_application",
reverse("api:enterprise:audit_log:list") + "?action_type=create_application",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -508,7 +495,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
# from timestamp
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list")
reverse("api:enterprise:audit_log:list")
+ "?from_timestamp=2023-01-01T12:00:01Z",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
@ -523,8 +510,7 @@ def test_audit_log_entries_can_be_filtered(api_client, enterprise_data_fixture):
# to timestamp
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list")
+ "?to_timestamp=2023-01-01T12:00:00Z",
reverse("api:enterprise:audit_log:list") + "?to_timestamp=2023-01-01T12:00:00Z",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -549,7 +535,7 @@ def test_audit_log_entries_return_400_for_invalid_values(
# an invalid value in the query params should return a 400
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list") + "?user_id=wrong_type",
reverse("api:enterprise:audit_log:list") + "?user_id=wrong_type",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -576,7 +562,7 @@ def test_audit_log_can_export_to_csv_all_entries(
execute=True
):
response = api_client.post(
reverse("api:enterprise:admin:audit_log:export"),
reverse("api:enterprise:audit_log:async_export"),
data=csv_settings,
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
@ -631,6 +617,7 @@ def test_audit_log_can_export_to_csv_filtered_entries(
"csv_column_separator": "|",
"csv_first_row_header": False,
"export_charset": "utf-8",
"exclude_columns": "ip_address",
}
filters = {
"filter_user_id": admin_user.id,
@ -642,7 +629,7 @@ def test_audit_log_can_export_to_csv_filtered_entries(
# if the action type is invalid, it should return a 400
response = api_client.post(
reverse("api:enterprise:admin:audit_log:export"),
reverse("api:enterprise:audit_log:async_export"),
data={**csv_settings, "filter_action_type": "wrong_type"},
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
@ -653,7 +640,7 @@ def test_audit_log_can_export_to_csv_filtered_entries(
execute=True
):
response = api_client.post(
reverse("api:enterprise:admin:audit_log:export"),
reverse("api:enterprise:audit_log:async_export"),
data={**csv_settings, **filters},
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
@ -731,7 +718,7 @@ def test_log_entries_still_work_correctly_if_the_action_type_is_removed(
action_type_registry.unregister(TemporaryActionType.type)
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list"),
reverse("api:enterprise:audit_log:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -775,7 +762,7 @@ def test_log_entries_still_work_correctly_if_the_action_type_is_removed(
action_type_registry.register(TemporaryActionTypeV2())
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list"),
reverse("api:enterprise:audit_log:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -805,7 +792,7 @@ def test_log_entries_still_work_correctly_if_the_action_type_is_removed(
assert AuditLogEntry.objects.count() == 2
response = api_client.get(
reverse("api:enterprise:admin:audit_log:list"),
reverse("api:enterprise:audit_log:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)

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 baserow.contrib.database.export.handler import ExportHandler
from baserow.core.actions import CreateWorkspaceActionType
from baserow.core.actions import CreateApplicationActionType, CreateWorkspaceActionType
from baserow.core.jobs.constants import JOB_FINISHED
from baserow.core.jobs.handler import JobHandler
from baserow_enterprise.audit_log.job_types import AuditLogExportJobType
@ -220,3 +220,52 @@ def test_audit_log_export_filters_work_correctly(
datetime.strptime("2023-01-01 12:00:08", "%Y-%m-%d %H:%M:%S")
)
assert job_type.get_filtered_queryset(job).count() == 0
@pytest.mark.django_db
@override_settings(DEBUG=True)
@patch("baserow.contrib.database.export.handler.default_storage")
def test_audit_log_export_workspace_csv_correctly(
storage_mock, enterprise_data_fixture, synced_roles
):
user, _ = enterprise_data_fixture.create_enterprise_admin_user_and_token()
workspace = enterprise_data_fixture.create_workspace(user=user)
with freeze_time("2023-01-01 12:00:00"):
app_1 = CreateApplicationActionType.do(user, workspace, "database", "App 1")
with freeze_time("2023-01-01 12:00:10"):
app_2 = CreateApplicationActionType.do(user, workspace, "database", "App 2")
csv_settings = {
"csv_column_separator": ",",
"csv_first_row_header": True,
"export_charset": "utf-8",
"filter_workspace_id": workspace.id,
"exclude_columns": "workspace_id,workspace_name",
}
stub_file = BytesIO()
storage_mock.open.return_value = stub_file
close = stub_file.close
stub_file.close = lambda: None
csv_export_job = JobHandler().create_and_start_job(
user, AuditLogExportJobType.type, **csv_settings, sync=True
)
csv_export_job.refresh_from_db()
assert csv_export_job.state == JOB_FINISHED
data = stub_file.getvalue().decode(csv_settings["export_charset"])
bom = "\ufeff"
assert data == (
bom
+ "User Email,User ID,Action Type,Description,Timestamp,IP Address\r\n"
+ f'{user.email},{user.id},Create application,"""{app_2.name}"" ({app_2.id}) database created '
+ f'in group ""{workspace.name}"" ({workspace.id}).",2023-01-01 12:00:10+00:00,\r\n'
+ f'{user.email},{user.id},Create application,"""{app_1.name}"" ({app_1.id}) database created '
+ f'in group ""{workspace.name}"" ({workspace.id}).",2023-01-01 12:00:00+00:00,\r\n'
)
close()

View file

@ -11,25 +11,11 @@ from baserow_enterprise.audit_log.handler import AuditLogHandler
from baserow_enterprise.audit_log.models import AuditLogEntry
@pytest.mark.django_db(transaction=True)
@override_settings(DEBUG=True)
def test_actions_are_not_inserted_as_audit_log_entries_without_license(
api_client, enterprise_data_fixture
):
user = enterprise_data_fixture.create_user()
with freeze_time("2023-01-01 12:00:00"):
CreateWorkspaceActionType.do(user, "workspace 1")
assert AuditLogEntry.objects.count() == 0
@pytest.mark.django_db
@override_settings(DEBUG=True)
def test_actions_are_inserted_as_audit_log_entries_with_license(
def test_actions_are_inserted_as_audit_log_entries_and_can_be_deleted_even_without_license(
api_client, enterprise_data_fixture, synced_roles
):
enterprise_data_fixture.enable_enterprise()
user = enterprise_data_fixture.create_user()
with freeze_time("2023-01-01 12:00:00"):
@ -40,6 +26,10 @@ def test_actions_are_inserted_as_audit_log_entries_with_license(
assert AuditLogEntry.objects.count() == 2
AuditLogHandler.delete_entries_older_than(datetime(2023, 1, 1, 13, 0, 0))
assert AuditLogEntry.objects.count() == 0
@pytest.mark.django_db
@override_settings(DEBUG=True)

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 {
margin-top: 30px;
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 class="audit-log__exported-list-item-info">
<div class="audit-log__exported-list-item-name">
{{
$t('auditLogExportModal.exportFilename', {
date: localDate(job.created_on),
})
}}
{{ getExportedFilenameTitle(job) }}
</div>
<div class="audit-log__exported-list-item-details">
{{ humanExportedAt(job.created_on) }}
@ -37,11 +33,7 @@
>
<div class="audit-log__exported-list-item-info">
<div class="audit-log__exported-list-item-name">
{{
$t('auditLogExportModal.exportFilename', {
date: localDate(finishedJob.created_on),
})
}}
{{ getExportedFilenameTitle(finishedJob) }}
</div>
<div class="audit-log__exported-list-item-details">
{{ humanExportedAt(finishedJob.created_on) }}
@ -67,8 +59,8 @@ import error from '@baserow/modules/core/mixins/error'
import moment from '@baserow/modules/core/moment'
import { getHumanPeriodAgoCount } from '@baserow/modules/core/utils/date'
import ExportLoadingBar from '@baserow/modules/database/components/export/ExportLoadingBar'
import AuditLogAdminService from '@baserow_enterprise/services/auditLogAdmin'
import AuditLogExportForm from '@baserow_enterprise/components/admin/forms/AuditLogExportForm'
import AuditLogAdminService from '@baserow_enterprise/services/auditLog'
const MAX_EXPORT_FILES = 4
@ -81,6 +73,10 @@ export default {
type: Object,
required: true,
},
workspaceId: {
type: Number,
default: null,
},
},
data() {
return {
@ -96,8 +92,13 @@ export default {
const jobs = await AuditLogAdminService(this.$client).getLastExportJobs(
MAX_EXPORT_FILES
)
this.lastFinishedJobs = jobs.filter((job) => job.state === 'finished')
const runningJob = jobs.find(
const filteredJobs = this.workspaceId
? jobs.filter((job) => job.filter_workspace_id === this.workspaceId)
: jobs
this.lastFinishedJobs = filteredJobs.filter(
(job) => job.state === 'finished'
)
const runningJob = filteredJobs.find(
(job) => !['failed', 'cancelled', 'finished'].includes(job.state)
)
this.job = runningJob || null
@ -129,6 +130,18 @@ export default {
getExportedFilename(job) {
return job ? `audit_log_${job.created_on}.csv` : ''
},
getExportedFilenameTitle(job) {
if (job.filter_workspace_id) {
return this.$t('auditLogExportModal.exportWorkspaceFilename', {
date: this.localDate(job.created_on),
workspaceId: job.filter_workspace_id,
})
} else {
return this.$t('auditLogExportModal.exportFilename', {
date: this.localDate(job.created_on),
})
}
},
humanExportedAt(timestamp) {
const { period, count } = getHumanPeriodAgoCount(timestamp)
return this.$tc(`datetime.${period}Ago`, count)
@ -154,11 +167,18 @@ export default {
value,
])
)
if (this.workspaceId) {
filters.filter_workspace_id = this.workspaceId
filters.exclude_columns = 'workspace_id,workspace_name'
}
try {
const { data } = await AuditLogAdminService(
this.$client
).startExportCsvJob({ ...values, ...filters })
).startExportCsvJob({
...values,
...filters,
})
this.lastFinishedJobs = this.lastFinishedJobs.slice(
0,
MAX_EXPORT_FILES - 1

View file

@ -8,7 +8,7 @@
"sidebarTooltip": "Your account has access to the enterprise features globally",
"rbac": "RBAC",
"sso": "SSO",
"deactivated": "Available in enterprise version",
"deactivated": "Available in the advanced/enterprise version",
"licenseDescription": "Viewers are free with Baserow Enterprise. If a user has any other role, in any workspace then they will use a paid seat automatically.",
"overflowWarning": "You have too many non-viewer users and have used up all of your paid seats. Change users to become viewers on each workspaces members page."
},
@ -61,7 +61,8 @@
"Authentication": "Authentication"
},
"auditLog": {
"title": "Audit log",
"adminTitle": "Audit log",
"workspaceTitle": "Audit log - {workspaceName}",
"filterUserTitle": "User",
"filterWorkspaceTitle": "Workspace",
"filterActionTypeTitle": "Event Type",
@ -84,7 +85,8 @@
},
"auditLogExportModal": {
"title": "Export to CSV",
"exportFilename": "Audit Log Export - {date}",
"exportFilename": "Admin Audit Log Export - {date}",
"exportWorkspaceFilename": " Workspace ({workspaceId}) Audit Log Export - {date}",
"cancelledTitle": "Export failed",
"cancelledDescription": "Something went wrong while exporting the audit log. Please try again."
},
@ -274,5 +276,9 @@
},
"snapshotModalWarning": {
"message": "Please be aware that a snapshot will include any permissions set on the application and its tables."
},
"auditLogSidebarWorkspace": {
"title": "Audit log",
"deactivated": "Available in the advanced/enterprise version"
}
}

View file

@ -3,6 +3,7 @@
<AuditLogExportModal
ref="exportModal"
:filters="filters"
:workspace-id="workspaceId"
></AuditLogExportModal>
<CrudTable
:columns="columns"
@ -13,7 +14,11 @@
row-id-key="id"
>
<template #title>
{{ $t('auditLog.title') }}
{{
workspaceId
? $t('auditLog.workspaceTitle', { workspaceName })
: $t('auditLog.adminTitle')
}}
</template>
<template #header-right-side>
<button
@ -24,7 +29,10 @@
</button>
</template>
<template #header-filters>
<div class="audit-log__filters">
<div
class="audit-log__filters"
:class="{ 'audit-log__filters--workspace': workspaceId }"
>
<FilterWrapper :name="$t('auditLog.filterUserTitle')">
<PaginatedDropdown
ref="userFilter"
@ -35,7 +43,10 @@
@input="filterUser"
></PaginatedDropdown>
</FilterWrapper>
<FilterWrapper :name="$t('auditLog.filterWorkspaceTitle')">
<FilterWrapper
v-if="!workspaceId"
:name="$t('auditLog.filterWorkspaceTitle')"
>
<PaginatedDropdown
ref="workspaceFilter"
:value="filters.workspace_id"
@ -88,7 +99,7 @@ import _ from 'lodash'
import moment from '@baserow/modules/core/moment'
import CrudTable from '@baserow/modules/core/components/crudTable/CrudTable'
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
import AuditLogAdminService from '@baserow_enterprise/services/auditLogAdmin'
import AuditLogService from '@baserow_enterprise/services/auditLog'
import DateFilter from '@baserow_enterprise/components/crudTable/filters/DateFilter'
import FilterWrapper from '@baserow_enterprise/components/crudTable/filters/FilterWrapper'
import SimpleField from '@baserow/modules/core/components/crudTable/fields/SimpleField'
@ -96,9 +107,10 @@ import LocalDateField from '@baserow/modules/core/components/crudTable/fields/Lo
import CrudTableColumn from '@baserow/modules/core/crudTable/crudTableColumn'
import LongTextField from '@baserow_enterprise/components/crudTable/fields/LongTextField'
import AuditLogExportModal from '@baserow_enterprise/components/admin/modals/AuditLogExportModal'
import EnterpriseFeatures from '@baserow_enterprise/features'
export default {
name: 'AuditLogAdminTable',
name: 'AuditLog',
components: {
AuditLogExportModal,
CrudTable,
@ -107,9 +119,40 @@ export default {
FilterWrapper,
},
layout: 'app',
middleware: 'staff',
middleware: 'authenticated',
asyncData({ app, error, route, store }) {
if (!app.$hasFeature(EnterpriseFeatures.AUDIT_LOG)) {
return error({
statusCode: 401,
message: 'Available in the advanced/enterprise version',
})
}
const workspaceId = route.params.workspaceId
? parseInt(route.params.workspaceId)
: null
if (workspaceId) {
if (
!app.$hasPermission(
'workspace.list_audit_log_entries',
store.getters['workspace/get'](workspaceId),
workspaceId
)
) {
return error({ statusCode: 404, message: 'Page not found' })
}
} else if (!store.getters['auth/isStaff']) {
return error({ statusCode: 403, message: 'Forbidden.' })
}
return { workspaceId }
},
data() {
this.columns = [
const filters = {}
const params = this.$route.params
const workspaceId = params.workspaceId ? parseInt(params.workspaceId) : null
const columns = [
new CrudTableColumn(
'user',
() => this.$t('auditLog.user'),
@ -120,64 +163,85 @@ export default {
{},
'15'
),
new CrudTableColumn(
'workspace',
() => this.$t('auditLog.workspace'),
SimpleField,
true,
false,
false,
{},
'15'
),
new CrudTableColumn(
'type',
() => this.$t('auditLog.actionType'),
SimpleField,
true,
false,
false,
{},
'10'
),
new CrudTableColumn(
'description',
() => this.$t('auditLog.description'),
LongTextField,
false,
false,
false,
{},
'40'
),
new CrudTableColumn(
'timestamp',
() => this.$t('auditLog.timestamp'),
LocalDateField,
true,
false,
false,
{ dateTimeFormat: 'L LTS' },
'10'
),
new CrudTableColumn(
'ip_address',
() => this.$t('auditLog.ip_address'),
SimpleField,
true,
false,
false,
{},
'10'
),
]
this.service = AuditLogAdminService(this.$client)
if (!workspaceId) {
columns.push(
new CrudTableColumn(
'workspace',
() => this.$t('auditLog.workspace'),
SimpleField,
true,
false,
false,
{},
'15'
)
)
} else {
filters.workspace_id = workspaceId
}
columns.push(
...[
new CrudTableColumn(
'type',
() => this.$t('auditLog.actionType'),
SimpleField,
true,
false,
false,
{},
'10'
),
new CrudTableColumn(
'description',
() => this.$t('auditLog.description'),
LongTextField,
false,
false,
false,
{},
'40'
),
new CrudTableColumn(
'timestamp',
() => this.$t('auditLog.timestamp'),
LocalDateField,
true,
false,
false,
{ dateTimeFormat: 'L LTS' },
'10'
),
new CrudTableColumn(
'ip_address',
() => this.$t('auditLog.ip_address'),
SimpleField,
true,
false,
false,
{},
'10'
),
]
)
this.columns = columns
this.service = AuditLogService(this.$client)
return {
filters: {},
filters,
dateTimeFormat: 'YYYY-MM-DDTHH:mm:ss.SSSZ',
}
},
computed: {
workspaceName() {
const selectedWorkspace = this.$store.getters['workspace/get'](
this.workspaceId
)
return selectedWorkspace ? selectedWorkspace.name : ''
},
disableDates() {
const minimumDate = moment('2023-01-01', 'YYYY-MM-DD')
const maximumDate = moment().add(1, 'day').endOf('day')
@ -186,6 +250,23 @@ export default {
from: maximumDate.toDate(),
}
},
selectedWorkspaceId() {
try {
return this.$store.getters['workspace/selectedId']
} catch (e) {
return null
}
},
},
watch: {
selectedWorkspaceId(newValue, oldValue) {
if (newValue !== oldValue && this.workspaceId) {
this.$router.push({
name: newValue ? 'workspace-audit-log' : 'dashboard',
params: { workspaceId: newValue },
})
}
},
},
methods: {
clearFilters() {
@ -196,7 +277,7 @@ export default {
'fromTimestampFilter',
'toTimestampFilter',
]) {
this.$refs[filterRef].clear()
this.$refs[filterRef]?.clear()
}
this.filters = {}
},
@ -215,7 +296,7 @@ export default {
this.setFilter('user_id', userId)
},
fetchUsers(page, search) {
return this.service.fetchUsers(page, search)
return this.service.fetchUsers(page, search, this.workspaceId)
},
filterWorkspace(workspaceId) {
this.setFilter('workspace_id', workspaceId)
@ -224,7 +305,7 @@ export default {
return this.service.fetchWorkspaces(page, search)
},
fetchActionTypes(page, search) {
return this.service.fetchActionTypes(page, search)
return this.service.fetchActionTypes(page, search, this.workspaceId)
},
filterActionType(actionTypeId) {
this.setFilter('action_type', actionTypeId)

View file

@ -1,5 +1,6 @@
import { BaserowPlugin } from '@baserow/modules/core/plugins'
import ChatwootSupportSidebarWorkspace from '@baserow_enterprise/components/ChatwootSupportSidebarWorkspace'
import AuditLogSidebarWorkspace from '@baserow_enterprise/components/AuditLogSidebarWorkspace'
import MemberRolesDatabaseContextItem from '@baserow_enterprise/components/member-roles/MemberRolesDatabaseContextItem'
import MemberRolesTableContextItem from '@baserow_enterprise/components/member-roles/MemberRolesTableContextItem'
import EnterpriseFeatures from '@baserow_enterprise/features'
@ -10,12 +11,17 @@ export class EnterprisePlugin extends BaserowPlugin {
return 'enterprise'
}
getSidebarWorkspaceComponent(workspace) {
getSidebarWorkspaceComponents(workspace) {
const supportEnabled = this.app.$hasFeature(
EnterpriseFeatures.SUPPORT,
workspace.id
)
return supportEnabled ? ChatwootSupportSidebarWorkspace : null
const sidebarItems = []
if (supportEnabled) {
sidebarItems.push(ChatwootSupportSidebarWorkspace)
}
sidebarItems.push(AuditLogSidebarWorkspace)
return sidebarItems
}
getAdditionalDatabaseContextComponents(workspace, database) {

View file

@ -19,6 +19,11 @@ export const routes = [
{
name: 'admin-audit-log',
path: '/admin/audit-log',
component: path.resolve(__dirname, 'pages/admin/auditLog.vue'),
component: path.resolve(__dirname, 'pages/auditLog.vue'),
},
{
name: 'workspace-audit-log',
path: '/workspace/:workspaceId/audit-log',
component: path.resolve(__dirname, 'pages/auditLog.vue'),
},
]

View file

@ -2,36 +2,44 @@ import baseService from '@baserow/modules/core/crudTable/baseService'
import jobService from '@baserow/modules/core/services/job'
export default (client) => {
return Object.assign(baseService(client, '/admin/audit-log/'), {
fetchUsers(page, search) {
const usersUrl = '/admin/audit-log/users/'
return Object.assign(baseService(client, `/audit-log/`), {
fetchUsers(page, search, workspaceId = null) {
const usersUrl = `/audit-log/users/`
const userPaginatedService = baseService(client, usersUrl)
return userPaginatedService.fetch(usersUrl, page, search, [], [])
const filters = {}
if (workspaceId) {
filters.workspace_id = workspaceId
}
return userPaginatedService.fetch(usersUrl, page, search, [], filters)
},
fetchWorkspaces(page, search) {
const workspacesUrl = '/admin/audit-log/workspaces/'
const workspacesUrl = `/audit-log/workspaces/`
const workspacePaginatedService = baseService(client, workspacesUrl)
return workspacePaginatedService.fetch(
workspacesUrl,
page,
search,
[],
[]
{}
)
},
fetchActionTypes(page, search) {
const actionTypesUrl = '/admin/audit-log/action-types/'
fetchActionTypes(page, search, workspaceId = null) {
const actionTypesUrl = `/audit-log/action-types/`
const actionTypePaginatedService = baseService(client, actionTypesUrl)
const filters = {}
if (workspaceId) {
filters.workspace_id = workspaceId
}
return actionTypePaginatedService.fetch(
actionTypesUrl,
page,
search,
[],
[]
filters
)
},
startExportCsvJob(data) {
return client.post('/admin/audit-log/export/', data)
return client.post(`/audit-log/export/`, data)
},
getExportJobInfo(jobId) {
return jobService(client).get(jobId)

View file

@ -23,10 +23,9 @@ from baserow.api.pagination import PageNumberPagination
from baserow.api.schemas import get_error_schema
class AdminListingView(
class APIListingView(
APIView, SearchableViewMixin, SortableViewMixin, FilterableViewMixin
):
permission_classes = (IsAdminUser,)
serializer_class = None
search_fields: List[str] = ["id"]
filters_field_mapping: Dict[str, str] = {}
@ -72,32 +71,37 @@ class AdminListingView(
@staticmethod
def get_extend_schema_parameters(
name, serializer_class, search_fields, sort_field_mapping
name, serializer_class, search_fields, sort_field_mapping, extra_parameters=None
):
"""
Returns the schema properties that can be used in in the @extend_schema
decorator.
"""
fields = sort_field_mapping.keys()
all_fields = ", ".join(fields)
field_name_1 = "field_1"
field_name_2 = "field_2"
for i, field in enumerate(fields):
if i == 0:
field_name_1 = field
if i == 1:
field_name_2 = field
return {
"parameters": [
parameters = []
if search_fields:
parameters.append(
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=f"If provided only {name} that match the query will "
f"be returned.",
),
description=f"If provided only {name} with {' or '.join(search_fields)} "
"that match the query will be returned.",
)
)
if sort_field_mapping:
fields = sort_field_mapping.keys()
all_fields = ", ".join(fields)
field_name_1 = "field_1"
field_name_2 = "field_2"
for i, field in enumerate(fields):
if i == 0:
field_name_1 = field
if i == 1:
field_name_2 = field
parameters.append(
OpenApiParameter(
name="sorts",
location=OpenApiParameter.QUERY,
@ -105,13 +109,18 @@ class AdminListingView(
description=f"A comma separated string of attributes to sort by, "
f"each attribute must be prefixed with `+` for a descending "
f"sort or a `-` for an ascending sort. The accepted attribute "
f"names are: {all_fields}. For example `sorts=-{field_name_1},"
f"names are: `{all_fields}`. For example `sorts=-{field_name_1},"
f"-{field_name_2}` will sort the {name} first by descending "
f"{field_name_1} and then ascending {field_name_2}. A sort"
f"parameter with multiple instances of the same sort attribute "
f"will respond with the ERROR_INVALID_SORT_ATTRIBUTE "
f"error.",
),
)
return {
"parameters": [
*parameters,
OpenApiParameter(
name="page",
location=OpenApiParameter.QUERY,
@ -125,6 +134,7 @@ class AdminListingView(
description=f"Defines how many {name} should be returned per "
f"page.",
),
*(extra_parameters or []),
],
"responses": {
200: serializer_class(many=True),
@ -139,3 +149,7 @@ class AdminListingView(
401: None,
},
}
class AdminListingView(APIListingView):
permission_classes = (IsAdminUser,)

View file

@ -35,6 +35,8 @@
}
.data-table__title {
@extend %ellipsis;
font-size: 24px;
line-height: 32px;
margin: 0;

View file

@ -448,8 +448,8 @@ export default {
},
sidebarWorkspaceComponents() {
return Object.values(this.$registry.getAll('plugin'))
.map((plugin) =>
plugin.getSidebarWorkspaceComponent(this.selectedWorkspace)
.flatMap((plugin) =>
plugin.getSidebarWorkspaceComponents(this.selectedWorkspace)
)
.filter((component) => component !== null)
},

View file

@ -144,6 +144,7 @@
"workspaceContext": {
"renameWorkspace": "Rename workspace",
"members": "Members",
"auditLog": "Audit log",
"viewTrash": "View trash",
"leaveWorkspace": "Leave 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
* the workspace context.
*/
getSidebarWorkspaceComponent(workspace) {
getSidebarWorkspaceComponents(workspace) {
return null
}

View file

@ -39,6 +39,7 @@ export default {
beforeRouteLeave(to, from, next) {
this.$store.dispatch('view/unselect')
this.$store.dispatch('table/unselect')
this.$store.dispatch('application/unselect')
next()
},
async beforeRouteUpdate(to, from, next) {