Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api/web): add audit log for create/update/delete #710

Merged
merged 10 commits into from
Oct 13, 2022
1 change: 1 addition & 0 deletions src/api/bkuser_core/api/login/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

# prefix: /api/v1/login/
urlpatterns = [
# NOTE: custom login used, so we can't remove it even the bkuser/login upgrade to use v2
path(
"check/",
views.ProfileLoginViewSet.as_view({"post": "login"}),
Expand Down
2 changes: 1 addition & 1 deletion src/api/bkuser_core/api/web/audit/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def to_representation(self, obj):
if extra_value["operation"] in OPERATION_ABOUT_PASSWORD
else (
f"{OPERATION_NAME_MAP[extra_value['operation']]}"
f"{OPERATION_OBJ_NAME_MAP[extra_value.get('obj_type')]}"
f"-[{OPERATION_OBJ_NAME_MAP[extra_value.get('obj_type')]}]"
)
)

Expand Down
66 changes: 25 additions & 41 deletions src/api/bkuser_core/api/web/audit/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
specific language governing permissions and limitations under the License.
"""
import logging
from datetime import datetime

from django.conf import settings
from django.db.models import Q
Expand All @@ -17,15 +18,12 @@

from .constants import OPERATION_OBJ_VALUE_MAP, OPERATION_VALUE_MAP
from .serializers import GeneralLogListInputSLZ, GeneralLogOutputSLZ, LoginLogListInputSLZ, LoginLogOutputSLZ
from bkuser_core.api.web.category.serializers import CategoryExportProfileOutputSLZ
from bkuser_core.api.web.export import ProfileExcelExporter
from bkuser_core.api.web.field.serializers import FieldOutputSLZ
from bkuser_core.api.web.export import LoginLogExcelExporter
from bkuser_core.api.web.utils import get_category_display_name_map
from bkuser_core.api.web.viewset import CustomPagination, StartTimeEndTimeFilterBackend
from bkuser_core.audit.models import GeneralLog, LogIn
from bkuser_core.bkiam.permissions import ViewAuditPermission
from bkuser_core.common.error_codes import error_codes
from bkuser_core.profiles.models import DynamicFieldInfo

logger = logging.getLogger(__name__)

Expand All @@ -51,10 +49,12 @@ def get_queryset(self):
keyword = data.get("keyword")
if keyword:
# FIXME: 这里有问题, 操作人员/操作对象/操作类型 => 查询不准
# 注意, GeneralLogOutputSLZ 展示在表格里的字段格式是: 更新-[用户] to 更新 to `update`
# 用户可能复制后粘贴搜索: 更新-[用户] to 更新 to `update`
keyword = keyword.split("-")[0]
for m in [OPERATION_OBJ_VALUE_MAP, OPERATION_VALUE_MAP]:
keyword = m.get(keyword, keyword)

keyword = keyword.encode("unicode-escape")
queryset = queryset.filter(Q(operator__icontains=keyword) | Q(extra_value__icontains=keyword))

return queryset
Expand Down Expand Up @@ -107,46 +107,30 @@ def get(self, request, *args, **kwargs):

# 提前获取用户
queryset.select_related("profile")

fields = DynamicFieldInfo.objects.filter(enabled=True).all()
fields_data = FieldOutputSLZ(fields, many=True).data
fields_data.append(
{
"id": len(fields_data) + 1,
"key": "datetime",
"name": "登录时间",
"options": [],
"display_name": "登录时间",
"type": "timer",
"require": True,
"unique": True,
"editable": True,
"configurable": True,
"builtin": False,
"order": 0,
"default": "",
"enabled": True,
"visible": True,
},
)
# TODO: 应该改造, 使用原生的登录审计: 用户-登录时间-来源 IP-登录状态-失败原因等等
# 可能再补充一些额外信息, 但是不应该导出profiles的信息

exporter = ProfileExcelExporter(
exporter = LoginLogExcelExporter(
load_workbook(settings.EXPORT_LOGIN_TEMPLATE),
settings.EXPORT_EXCEL_FILENAME + "_login_audit",
fields_data,
1,
)

context = {"category_name_map": get_category_display_name_map()}
login_logs = queryset.all()

profiles = [x.profile for x in login_logs]
all_profiles = CategoryExportProfileOutputSLZ(profiles, many=True).data

# FIXME: bug here, 这里的key是profile.id, 会导致每个用户只有一条登录审计记录 => 这是有问题的
extra_info = {x.profile.id: LoginLogOutputSLZ(x, context=context).data for x in login_logs}
exporter.update_profiles(all_profiles, extra_info)

records = []
for login_log in login_logs:
record = LoginLogOutputSLZ(login_log, context=context).data
dt = record["datetime"]
if isinstance(dt, datetime):
dt = dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")

records.append(
{
"username": login_log.profile.username,
"display_name": login_log.profile.display_name,
"ip": record["client_ip"],
"status": "成功" if record["is_success"] else "失败",
"datetime": dt,
"reason": record["reason"] or "-",
}
)

exporter.add_records(records)
return exporter.to_response()
60 changes: 43 additions & 17 deletions src/api/bkuser_core/api/web/category/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
from bkuser_core.api.web.field.serializers import FieldOutputSLZ
from bkuser_core.api.web.utils import get_category, get_operator, list_setting_metas
from bkuser_core.api.web.viewset import CustomPagination
from bkuser_core.audit.constants import OperationType
from bkuser_core.audit.utils import audit_general_log
from bkuser_core.bkiam.exceptions import IAMPermissionDenied
from bkuser_core.bkiam.permissions import (
IAMAction,
Expand All @@ -61,14 +63,11 @@
from bkuser_core.departments.models import Department, DepartmentThroughModel
from bkuser_core.profiles.models import DynamicFieldInfo, Profile
from bkuser_core.user_settings.models import Setting
from bkuser_core.user_settings.signals import post_setting_create, post_setting_update

logger = logging.getLogger(__name__)


# FIXME: 统一加
# @audit_general_log(operate_type=OperationType.DELETE.value)


class CategoryMetasListApi(generics.ListAPIView):
@classmethod
def make_meta(cls, type_: CategoryType):
Expand Down Expand Up @@ -145,12 +144,30 @@ def post(self, request, *args, **kwargs):

try:
# 暂时忽略已创建报错
setting, _ = Setting.objects.update_or_create(meta=meta, value=setting["value"], category=category)
setting, created = Setting.objects.update_or_create(
meta=meta, value=setting["value"], category=category
)
except Exception:
logger.exception(
"cannot create setting. [meta=%s, value=%s, category=%s]", metas[0], setting["value"], category
)
raise error_codes.CANNOT_CREATE_SETTING
else:
if created:
post_setting_create.send(
sender=self,
instance=setting,
operator=request.operator,
extra_values={"request": request},
)
else:
# 仅当更新成功时才发送信号
post_setting_update.send(
sender=self,
instance=setting,
operator=request.operator,
extra_values={"request": request},
)

settings = Setting.objects.filter(meta__in=metas, category_id=category_id).all()
return Response(self.get_serializer_class()(settings, many=True).data)
Expand Down Expand Up @@ -185,7 +202,7 @@ def put(self, request, *args, **kwargs):
if key not in metas_map:
continue
meta = metas_map[key]
Setting.objects.update_or_create(
s, created = Setting.objects.update_or_create(
meta=meta,
category_id=category_id,
defaults={
Expand All @@ -194,18 +211,25 @@ def put(self, request, *args, **kwargs):
},
)

if created:
post_setting_create.send(
sender=self,
instance=s,
operator=request.operator,
extra_values={"request": request},
)
else:
# 仅当更新成功时才发送信号
post_setting_update.send(
sender=self,
instance=s,
operator=request.operator,
extra_values={"request": request},
)

# Setting.objects.filter(meta__in=metas, category_id=category_id).all()
return Response(self.get_serializer_class()(db_settings, many=True).data)

# FIXME: 原来是调用 N 次api, N 个信号, 现在一次就更新成功了
# # 仅当更新成功时才发送信号
# post_setting_update.send(
# sender=self,
# instance=self.get_object(),
# operator=request.operator,
# extra_values={"request": request},
# )


class CategoryListCreateApi(generics.ListCreateAPIView):
serializer_class = CategoryDetailOutputSLZ
Expand Down Expand Up @@ -259,6 +283,7 @@ class CategoryUpdateDeleteApi(generics.RetrieveUpdateDestroyAPIView):
queryset = ProfileCategory.objects.all()
lookup_url_kwarg = "id"

@audit_general_log(operate_type=OperationType.UPDATE.value)
def patch(self, request, *args, **kwargs):
instance = self.get_object()
slz = CategoryUpdateInputSLZ(instance, data=request.data, partial=True)
Expand Down Expand Up @@ -424,15 +449,15 @@ class CategoryOperationSyncOrImportApi(generics.CreateAPIView):
FileUploadParser,
]

# @audit_general_log(operate_type=OperationType.SYNC.value)
# @audit_general_log(operate_type=OperationType.IMPORT.value)
@audit_general_log(operate_type=OperationType.SYNC.value)
def post(self, request, *args, **kwargs):
instance = self.get_object()
if instance.type == CategoryType.LOCAL.value:
return self._local_category_do_import(request, instance)
else:
return self._not_local_category_do_sync(request, instance)

# @audit_general_log(operate_type=OperationType.IMPORT.value)
def _local_category_do_import(self, request, instance):
"""向本地目录导入数据文件"""
slz = CategoryFileImportInputSLZ(data=request.data)
Expand Down Expand Up @@ -517,6 +542,7 @@ class CategoryOperationSwitchOrderApi(generics.UpdateAPIView):
queryset = ProfileCategory.objects.filter(enabled=True)
lookup_url_kwarg = "id"

@audit_general_log(operate_type=OperationType.UPDATE.value)
def patch(self, request, *args, **kwargs):
instance = self.get_object()

Expand Down
8 changes: 8 additions & 0 deletions src/api/bkuser_core/api/web/department/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
)
from bkuser_core.api.web.utils import get_category, get_default_category_id, get_department, get_operator
from bkuser_core.api.web.viewset import CustomPagination
from bkuser_core.audit.constants import OperationType
from bkuser_core.audit.utils import audit_general_log
from bkuser_core.bkiam.permissions import IAMAction, ManageDepartmentPermission, Permission, ViewDepartmentPermission
from bkuser_core.categories.models import ProfileCategory
from bkuser_core.common.error_codes import error_codes
Expand Down Expand Up @@ -108,6 +110,8 @@ class DepartmentRetrieveUpdateDeleteApi(generics.RetrieveUpdateDestroyAPIView):

permission_classes = [ManageDepartmentPermission]

# NOTE: no audit log here

def delete(self, request, *args, **kwargs):
instance = self.get_object()
# 当组织存在下级时无法删除
Expand Down Expand Up @@ -148,6 +152,8 @@ class DepartmentOperationSwitchOrderApi(generics.UpdateAPIView):
queryset = Department.objects.filter(enabled=True)
lookup_url_kwarg = "id"

# NOTE: no audit log here

def patch(self, request, *args, **kwargs):
instance = self.get_object()

Expand Down Expand Up @@ -231,6 +237,8 @@ def list(self, request, *args, **kwargs):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

# NOTE: here for department add profiles, it's a update for department
@audit_general_log(operate_type=OperationType.UPDATE.value)
def create(self, request, *args, **kwargs):
slz = DepartmentProfilesCreateInputSLZ(data=self.request.data)
slz.is_valid(raise_exception=True)
Expand Down
84 changes: 83 additions & 1 deletion src/api/bkuser_core/api/web/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ def _update_sheet_titles(self):

field_col_map = {}

# NOTE: 这里required_field_names 跟excel表格中对不上了, 两边的意义不一样(模型定义是required, 但在excel导入是非必填的)
# 所以, 我们这里使用枚举的方式, 保证导入/导出的标红title一致

red_ft = Font(color=colors.COLOR_INDEX[2])
black_ft = Font(color=colors.BLACK)
for index, field_name in enumerate(required_field_names):
Expand All @@ -165,7 +168,11 @@ def _update_sheet_titles(self):
column=column,
value=field_name,
)
_cell.font = red_ft
if field_name in ("全名", "用户名", "邮箱", "手机号", "组织"):
_cell.font = red_ft
else:
_cell.font = black_ft

field_col_map[field_name] = index + 1

for index, field_name in enumerate(not_required_field_names):
Expand All @@ -185,3 +192,78 @@ def to_response(self) -> HttpResponse:
response["Content-Disposition"] = f"attachment;filename={self.exported_file_name}.xlsx"
self.workbook.save(response)
return response


class LoginLogExcelExporter:
"""登录审计日志导出"""

workbook: "Workbook"
exported_file_name: str
fields: list = [
{
"title": "登录用户",
"name": "username",
},
{
"title": "用户全名",
"name": "display_name",
},
{
"title": "登录时间",
"name": "datetime",
},
{
"title": "登录来源IP",
"name": "ip",
},
{
"title": "登录状态",
"name": "status",
},
{
"title": "登录失败原因",
"name": "reason",
},
]
title_row_index: int = 1

def __init__(self, webhook: "Workbook", exported_file_name: str):
self.workbook = webhook
self.exported_file_name = exported_file_name

self.first_sheet = self.workbook.worksheets[0]
# 样式加载
self.first_sheet.alignment = Alignment(wrapText=True)
# 初始化全表的单元格数据格式
# 将单元格设置为纯文本模式,预防DDE
for columns in self.first_sheet.columns:
for cell in columns:
cell.number_format = FORMAT_TEXT

def to_response(self) -> HttpResponse:
response = HttpResponse(content_type="application/ms-excel")
response["Content-Disposition"] = f"attachment;filename={self.exported_file_name}.xlsx"
self.workbook.save(response)
return response

def add_records(self, records):
self._update_sheet_titles()
for p_index, p in enumerate(records):
for f_index, field in enumerate(self.fields):
field_name = field["name"]
value = p[field_name]
self.first_sheet.cell(row=p_index + self.title_row_index + 1, column=f_index + 1, value=value)

def _update_sheet_titles(self):
"""更新表格标题"""
black_ft = Font(color=colors.BLACK)

for index, field in enumerate(self.fields):
# column = index + 1 + len(self.fields)
column = index + 1
_cell = self.first_sheet.cell(
row=self.title_row_index,
column=column,
value=field["title"],
)
_cell.font = black_ft
Loading