From ac3218bb0b150ca7ae74ecf275c337c1bddfdb2b Mon Sep 17 00:00:00 2001 From: alex-smile <443677891@qq.com> Date: Mon, 18 Dec 2023 09:53:59 +0800 Subject: [PATCH] =?UTF-8?q?ESB=20=E7=BB=84=E4=BB=B6=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=BB=BA=E5=8D=95=E3=80=81=E6=9F=A5=E8=AF=A2=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E7=BD=91=E5=85=B3=20bk-esb=20=E6=9D=83=E9=99=90=E5=8D=95?= =?UTF-8?q?=E3=80=81=E6=95=B0=E6=8D=AE=E5=85=B3=E8=81=94=E8=B5=B7=E6=9D=A5?= =?UTF-8?q?=20(#399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apis/open/esb/permission/helpers.py | 151 ----- .../apis/open/esb/permission/views.py | 86 +-- .../apigateway/apis/open/permission/views.py | 45 +- .../apigateway/apps/permission/tasks.py | 3 +- .../apigateway/biz/esb/permissions.py | 422 +++++++++++++ .../apigateway/apigateway/biz/permission.py | 46 ++ .../apigateway/apigateway/conf/default.py | 3 + .../editions/ee/apps/esb/bkcore/managers.py | 10 +- ...sionapplyrecord_gateway_apply_record_id.py | 18 + .../editions/ee/apps/esb/bkcore/models.py | 5 + .../ee/tests/apps/esb/bkcore/test_managers.py | 39 ++ .../apis/open/esb/permission/test_helpers.py | 263 -------- .../apis/open/esb/permission/test_views.py | 84 +-- .../apigateway/tests/biz/esb/conftest.py | 31 + .../esb/test_component_resource_binding.py | 3 + .../tests/biz/esb/test_permissions.py | 565 ++++++++++++++++++ .../apigateway/tests/biz/test_permission.py | 33 + 17 files changed, 1253 insertions(+), 554 deletions(-) delete mode 100644 src/dashboard/apigateway/apigateway/apis/open/esb/permission/helpers.py create mode 100644 src/dashboard/apigateway/apigateway/biz/esb/permissions.py create mode 100644 src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/migrations/0014_apppermissionapplyrecord_gateway_apply_record_id.py create mode 100644 src/dashboard/apigateway/apigateway/editions/ee/tests/apps/esb/bkcore/test_managers.py delete mode 100644 src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_helpers.py create mode 100644 src/dashboard/apigateway/apigateway/tests/biz/esb/conftest.py create mode 100644 src/dashboard/apigateway/apigateway/tests/biz/esb/test_permissions.py diff --git a/src/dashboard/apigateway/apigateway/apis/open/esb/permission/helpers.py b/src/dashboard/apigateway/apigateway/apis/open/esb/permission/helpers.py deleted file mode 100644 index 676f1e452..000000000 --- a/src/dashboard/apigateway/apigateway/apis/open/esb/permission/helpers.py +++ /dev/null @@ -1,151 +0,0 @@ -# -*- coding: utf-8 -*- -# -# TencentBlueKing is pleased to support the open source community by making -# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available. -# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. -# Licensed under the MIT License (the "License"); you may not use this file except -# in compliance with the License. You may obtain a copy of the License at -# -# http://opensource.org/licenses/MIT -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, -# either express or implied. See the License for the specific language governing permissions and -# limitations under the License. -# -# We undertake not to change the open source license (MIT license) applicable -# to the current version of the project delivered to anyone in the future. -# -import copy -import math -from dataclasses import dataclass -from typing import List, Optional, Union - -from django.utils.functional import cached_property -from pydantic import BaseModel, parse_obj_as - -from apigateway.apps.esb.bkcore.models import AppComponentPermission, AppPermissionApplyRecord -from apigateway.apps.esb.helpers import get_component_doc_link -from apigateway.apps.permission.constants import ApplyStatusEnum, PermissionLevelEnum, PermissionStatusEnum - - -class ComponentPermission(BaseModel): - class Config: - arbitrary_types_allowed = True - keep_untouched = (cached_property,) - - id: int - board: str - name: str - description: str - description_en: Optional[str] = None - system_name: str - permission_level: str - component_permission: Optional[AppComponentPermission] - component_permission_apply_status: Optional[str] - - def as_dict(self): - return { - "board": self.board, - "id": self.id, - "name": self.name, - "system_name": self.system_name, - "description": self.description, - "description_en": self.description_en, - "permission_level": self.permission_level, - "permission_status": self.permission_status, - "expires_in": self.expires_in, - "doc_link": self.doc_link, - } - - @property - def component_perm_required(self) -> bool: - return self.permission_level != PermissionLevelEnum.UNLIMITED.value - - @property - def doc_link(self): - return get_component_doc_link( - board=self.board, - system_name=self.system_name, - component_name=self.name, - ) - - @property - def permission_status(self) -> str: - # 如果组件不需要权限校验,则权限类型为:无限制,即默认拥有权限 - if not self.component_perm_required: - return PermissionStatusEnum.UNLIMITED.value - - # 如果权限记录中,有效期为永久有效;权限不需要再申请,优先展示 - if self.expires_in == math.inf: - return PermissionStatusEnum.OWNED.value - - # 如果权限已有申请状态,如已拒绝、申请中;优先展示 - if self.component_permission_apply_status: - return self.component_permission_apply_status - - # 有权限且未过期 - if self.expires_in > 0: - return PermissionStatusEnum.OWNED.value - - # 有权限,但是已过期 - if self.expires_in > -math.inf: - return PermissionStatusEnum.EXPIRED.value - - # 无权限,待申请 - return PermissionStatusEnum.NEED_APPLY.value - - @cached_property - def expires_in(self) -> Union[int, float]: - if not self.component_perm_required: - return math.inf - - return self._get_component_permission_expires_in() - - def _get_component_permission_expires_in(self) -> Union[int, float]: - if not self.component_permission: - return -math.inf - - return self._normalize_expires_in(self.component_permission.expires_in) - - def _normalize_expires_in(self, expires_in) -> Union[int, float]: - # 指定的过期时间为None,表示不过期,过期时间设置为 math.inf - if expires_in is None: - return math.inf - - return expires_in - - -@dataclass -class ComponentPermissionBuilder: - system_id: Optional[int] - target_app_code: str - - def build(self, components: list) -> list: - component_ids = [component["id"] for component in components] - component_permission_map = self._get_component_permission_map(component_ids) - component_permission_apply_status_map = AppPermissionApplyRecord.objects.get_component_permisson_status( - self.target_app_code, - self.system_id, - [ApplyStatusEnum.PENDING.value], - ) - - components = copy.copy(components) - for component in components: - component["component_permission"] = component_permission_map.get(component["id"]) - component["component_permission_apply_status"] = component_permission_apply_status_map.get( - component["id"], None - ) - - component_permissions = parse_obj_as(List[ComponentPermission], components) - - return [perm.as_dict() for perm in component_permissions] - - def _get_component_permission_map(self, component_ids: List[int]): - return { - perm.component_id: perm - for perm in AppComponentPermission.objects.filter( - bk_app_code=self.target_app_code, - component_id__in=component_ids, - ) - } diff --git a/src/dashboard/apigateway/apigateway/apis/open/esb/permission/views.py b/src/dashboard/apigateway/apigateway/apis/open/esb/permission/views.py index 6ba4f9b90..acc87cda2 100644 --- a/src/dashboard/apigateway/apigateway/apis/open/esb/permission/views.py +++ b/src/dashboard/apigateway/apigateway/apis/open/esb/permission/views.py @@ -19,23 +19,18 @@ import logging import operator -from blue_krill.async_utils.django_utils import apply_async_on_commit from django.db import transaction from drf_yasg.utils import swagger_auto_schema from rest_framework import status, viewsets from apigateway.apis.open.esb.permission import serializers -from apigateway.apis.open.esb.permission.helpers import ComponentPermissionBuilder from apigateway.apps.esb.bkcore.models import ( - AppComponentPermission, AppPermissionApplyRecord, - AppPermissionApplyStatus, ComponentSystem, ESBChannel, ) from apigateway.apps.esb.permission.serializers import AppPermissionApplyRecordDetailSLZ -from apigateway.apps.permission.constants import ApplyStatusEnum -from apigateway.apps.permission.tasks import send_mail_for_perm_apply +from apigateway.biz.esb.permissions import ComponentPermissionManager from apigateway.common.error_codes import error_codes from apigateway.utils.responses import V1OKJsonResponse @@ -57,16 +52,14 @@ def list(self, request, system_id: int, *args, **kwargs): queryset = ESBChannel.objects.filter_active_and_public_components(system_id=system_id) components = ESBChannel.objects.get_components(queryset) - component_permissions = ComponentPermissionBuilder( - system_id, - slz.validated_data["target_app_code"], - ).build(components) + manager = ComponentPermissionManager.get_manager() + component_permissions = manager.list_permissions(slz.validated_data["target_app_code"], system_id, components) - slz = self.get_serializer( + output_slz = self.get_serializer( sorted(component_permissions, key=operator.itemgetter("permission_level", "name")), many=True, ) - return V1OKJsonResponse("OK", data=slz.data) + return V1OKJsonResponse("OK", data=output_slz.data) class AppPermissionApplyV1APIView(viewsets.GenericViewSet): @@ -90,37 +83,15 @@ def apply(self, request, system_id: int, *args, **kwargs): data = slz.validated_data - for component_ids in ESBChannel.objects.group_by_permission_level(data["component_ids"]): - instance = AppPermissionApplyRecord.objects.create_record( - board=system.board, - bk_app_code=data["target_app_code"], - applied_by=request.user.username, - system=system, - component_ids=component_ids, - status=ApplyStatusEnum.PENDING.value, - reason=data["reason"], - expire_days=data["expire_days"], - ) - - if AppPermissionApplyStatus is not None: - # 删除应用-组件申请状态的历史记录,方便下面批量插入 - AppPermissionApplyStatus.objects.filter( - bk_app_code=data["target_app_code"], - system=system, - component_id__in=component_ids, - ).delete() - AppPermissionApplyStatus.objects.batch_create( - record=instance, - bk_app_code=data["target_app_code"], - system=system, - component_ids=component_ids, - status=ApplyStatusEnum.PENDING.value, - ) - - try: - apply_async_on_commit(send_mail_for_perm_apply, args=[instance.id]) - except Exception: - logger.exception("send mail to gateway manager fail. apply_record_id=%s", instance.id) + manager = ComponentPermissionManager.get_manager() + manager.create_apply_record( + data["target_app_code"], + system, + data["component_ids"], + data["reason"], + data["expire_days"], + request.user.username, + ) return V1OKJsonResponse("OK") @@ -138,7 +109,8 @@ def renew(self, request, *args, **kwargs): data = slz.validated_data - AppComponentPermission.objects.renew_permissions( + manager = ComponentPermissionManager.get_manager() + manager.renew_permission( data["target_app_code"], data["component_ids"], data["expire_days"], @@ -155,20 +127,11 @@ def list(self, request, *args, **kwargs): data = slz.validated_data - component_ids = AppComponentPermission.objects.filter_component_ids( - bk_app_code=data["target_app_code"], - expire_days_range=data.get("expire_days_range"), - ) - queryset = ESBChannel.objects.filter_active_and_public_components( - ids=component_ids, - allow_apply_permission=True, - ) - components = ESBChannel.objects.get_components(queryset) - - component_permissions = ComponentPermissionBuilder( - None, + manager = ComponentPermissionManager.get_manager() + component_permissions = manager.list_applied_permissions( data["target_app_code"], - ).build(components) + data.get("expire_days_range"), + ) slz = serializers.AppPermissionComponentSLZ(component_permissions, many=True) return V1OKJsonResponse("OK", data=sorted(slz.data, key=operator.itemgetter("system_name", "name"))) @@ -195,7 +158,11 @@ def list(self, request, *args, **kwargs): order_by="-id", ) - page = self.paginate_queryset(queryset) + page = list(self.paginate_queryset(queryset)) + + manager = ComponentPermissionManager.get_manager() + manager.patch_permission_apply_records(page) + slz = serializers.AppPermissionApplyRecordV1SLZ(page, many=True) return V1OKJsonResponse("OK", data=self.paginator.get_paginated_data(slz.data)) @@ -210,5 +177,8 @@ def retrieve(self, request, record_id: int, *args, **kwargs): except AppPermissionApplyRecord.DoesNotExist: raise error_codes.NOT_FOUND + manager = ComponentPermissionManager.get_manager() + manager.patch_permission_apply_records([record]) + slz = AppPermissionApplyRecordDetailSLZ(record) return V1OKJsonResponse("OK", data=slz.data) diff --git a/src/dashboard/apigateway/apigateway/apis/open/permission/views.py b/src/dashboard/apigateway/apigateway/apis/open/permission/views.py index 3706c1548..57d1361e7 100644 --- a/src/dashboard/apigateway/apigateway/apis/open/permission/views.py +++ b/src/dashboard/apigateway/apigateway/apis/open/permission/views.py @@ -31,14 +31,12 @@ ResourcePermissionBuilder, ) from apigateway.apps.permission.constants import ( - ApplyStatusEnum, GrantDimensionEnum, GrantTypeEnum, PermissionApplyExpireDaysEnum, ) from apigateway.apps.permission.models import ( AppGatewayPermission, - AppPermissionApply, AppPermissionRecord, AppResourcePermission, ) @@ -50,7 +48,6 @@ from apigateway.common.permissions import GatewayRelatedAppPermission from apigateway.core.models import Gateway, Resource from apigateway.utils.responses import V1OKJsonResponse -from apigateway.utils.time import now_datetime from . import serializers @@ -140,43 +137,21 @@ def post(self, request, *args, **kwargs): data = slz.validated_data - record = AppPermissionRecord.objects.create( - bk_app_code=data["target_app_code"], - applied_by=request.user.username, - applied_time=now_datetime(), - reason=data["reason"], - expire_days=data.get("expire_days", PermissionApplyExpireDaysEnum.FOREVER.value), - gateway=request.gateway, - resource_ids=data.get("resource_ids", []), - grant_dimension=data["grant_dimension"], - status=ApplyStatusEnum.PENDING.value, - ) - - instance = AppPermissionApply.objects.create( - bk_app_code=data["target_app_code"], - applied_by=request.user.username, - gateway=request.gateway, - resource_ids=data.get("resource_ids", []), - grant_dimension=data["grant_dimension"], - status=ApplyStatusEnum.PENDING.value, - reason=data["reason"], - expire_days=data.get("expire_days", PermissionApplyExpireDaysEnum.FOREVER.value), - apply_record_id=record.id, - ) - manager = PermissionDimensionManager.get_manager(data["grant_dimension"]) - manager.save_permission_apply_status( - bk_app_code=data["target_app_code"], - gateway=request.gateway, - apply=instance, - status=ApplyStatusEnum.PENDING.value, - resources=Resource.objects.filter(gateway=request.gateway, id__in=data.get("resource_ids") or []), + record = manager.create_apply_record( + data["target_app_code"], + request.gateway, + data.get("resource_ids") or [], + data["grant_dimension"], + data["reason"], + data.get("expire_days", PermissionApplyExpireDaysEnum.FOREVER.value), + request.user.username, ) try: - apply_async_on_commit(send_mail_for_perm_apply, args=[instance.id]) + apply_async_on_commit(send_mail_for_perm_apply, args=[record.id]) except Exception: - logger.exception("send mail to gateway manager fail. apply_record_id=%s", instance.id) + logger.exception("send mail to gateway manager fail. apply_record_id=%s", record.id) return V1OKJsonResponse( "OK", diff --git a/src/dashboard/apigateway/apigateway/apps/permission/tasks.py b/src/dashboard/apigateway/apigateway/apps/permission/tasks.py index 9aabae15c..cb18e9c19 100644 --- a/src/dashboard/apigateway/apigateway/apps/permission/tasks.py +++ b/src/dashboard/apigateway/apigateway/apps/permission/tasks.py @@ -37,7 +37,6 @@ ) from apigateway.apps.permission.models import ( AppGatewayPermission, - AppPermissionApply, AppPermissionRecord, AppResourcePermission, ) @@ -61,7 +60,7 @@ def send_mail_for_perm_apply(record_id): """ 申请权限,发送邮件通知管理员审批 """ - record = AppPermissionApply.objects.get(id=record_id) + record = AppPermissionRecord.objects.get(id=record_id) apigw_domain = getattr(settings, "DASHBOARD_FE_URL", "").rstrip("/") manager = PermissionDimensionManager.get_manager(record.grant_dimension) diff --git a/src/dashboard/apigateway/apigateway/biz/esb/permissions.py b/src/dashboard/apigateway/apigateway/biz/esb/permissions.py new file mode 100644 index 000000000..dc5041fb5 --- /dev/null +++ b/src/dashboard/apigateway/apigateway/biz/esb/permissions.py @@ -0,0 +1,422 @@ +# +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +# +import copy +import math +from typing import Any, Dict, List, Optional, Union + +from django.conf import settings +from django.db import transaction +from django.utils.functional import cached_property +from pydantic import BaseModel, parse_obj_as + +from apigateway.apps.esb.bkcore.models import ( + AppComponentPermission, + AppPermissionApplyRecord, + AppPermissionApplyStatus, + ComponentResourceBinding, + ComponentSystem, + ESBChannel, +) +from apigateway.apps.esb.helpers import get_component_doc_link +from apigateway.apps.esb.utils import get_esb_gateway +from apigateway.apps.permission.constants import ( + ApplyStatusEnum, + GrantDimensionEnum, + GrantTypeEnum, + PermissionLevelEnum, + PermissionStatusEnum, +) +from apigateway.apps.permission.models import AppPermissionApplyStatus as GatewayAppPermissionApplyStatus +from apigateway.apps.permission.models import AppPermissionRecord as GatewayAppPermissionRecord +from apigateway.apps.permission.models import AppResourcePermission +from apigateway.biz.permission import PermissionDimensionManager +from apigateway.utils.time import to_datetime_from_now + + +class ComponentPermissionManager: + """ + 组件权限管理 + - 未使用网关 bk-esb 管理组件权限时,直接使用组件的权限数据,权限单据 + - 使用网关 bk-esb 管理组件权限时(开源版),通过 ComponentResourceBinding 将组件和网关 bk-esb 的资源关联起来,然后使用网关的权限数据,单据审批结果 + """ + + @classmethod + def get_manager(cls) -> "ComponentPermissionManager": + if settings.USE_GATEWAY_BK_ESB_MANAGE_COMPONENT_PERMISSIONS and ComponentResourceBinding: + return ComponentPermissionByGatewayManager() + + return ComponentPermissionByEsbManager() + + @transaction.atomic + def create_apply_record( + self, + bk_app_code: str, + system: ComponentSystem, + component_ids: List[int], + reason: str, + expire_days: int, + username: str, + ) -> AppPermissionApplyRecord: + """创建权限申请单""" + record = AppPermissionApplyRecord.objects.create_record( + board=system.board, + bk_app_code=bk_app_code, + applied_by=username, + system=system, + component_ids=component_ids, + status=ApplyStatusEnum.PENDING.value, + reason=reason, + expire_days=expire_days, + ) + + # 在旧版中,AppPermissionApplyStatus 不存在,为保持模型引用的兼容,将其设置为了 None + if AppPermissionApplyStatus is not None: + # 删除应用-组件申请状态的历史记录,方便下面批量插入 + AppPermissionApplyStatus.objects.filter( + bk_app_code=bk_app_code, + system=system, + component_id__in=component_ids, + ).delete() + AppPermissionApplyStatus.objects.batch_create( + record=record, + bk_app_code=bk_app_code, + system=system, + component_ids=component_ids, + status=ApplyStatusEnum.PENDING.value, + ) + + return record + + def renew_permission(self, bk_app_code: str, component_ids: List[int], expire_days: int): + """权限续期""" + AppComponentPermission.objects.renew_permissions( + bk_app_code, + component_ids, + expire_days, + ) + + def list_permissions(self, bk_app_code: str, system_id: Optional[int], components: List[Dict[str, Any]]): + """权限列表""" + component_ids = [component["id"] for component in components] + component_permission_map = { + perm.component_id: AppComponentPermissionData( + expires_in=perm.expires_in, + ) + for perm in AppComponentPermission.objects.filter(bk_app_code=bk_app_code, component_id__in=component_ids) + } + component_permission_apply_status_map = AppPermissionApplyRecord.objects.get_component_permission_status( + bk_app_code, + system_id, + # 这里可以包含 PENDING,以及最新审批且状态为 REJECTED 的单据;但是旧版中,是根据历史单据 records 分析的,获取此 REJECTED 较复杂, + # 所以这里只获取 PENDING + [ApplyStatusEnum.PENDING.value], + ) + + components = copy.copy(components) + for component in components: + component["component_permission"] = component_permission_map.get(component["id"]) + component["component_permission_apply_status"] = component_permission_apply_status_map.get( + component["id"], None + ) + + component_permissions = parse_obj_as(List[ComponentPermission], components) + + return [perm.as_dict() for perm in component_permissions] + + def list_applied_permissions(self, bk_app_code: str, expire_days_range: Optional[int]): + """已申请权限的列表""" + component_ids = AppComponentPermission.objects.filter_component_ids( + bk_app_code=bk_app_code, + expire_days_range=expire_days_range, + ) + queryset = ESBChannel.objects.filter_active_and_public_components( + ids=component_ids, + allow_apply_permission=True, + ) + components = ESBChannel.objects.get_components(queryset) + return self.list_permissions(bk_app_code, None, components) + + def patch_permission_apply_records(self, records: List[AppPermissionApplyRecord]): + pass + + +class ComponentPermissionByEsbManager(ComponentPermissionManager): + """根据 ESB 数据,处理组件权限数据""" + + +class ComponentPermissionByGatewayManager(ComponentPermissionManager): + """根据网关 bk-esb 权限数据,处理组件权限数据""" + + @transaction.atomic + def create_apply_record( + self, + bk_app_code: str, + system: ComponentSystem, + component_ids: List[int], + reason: str, + expire_days: int, + username: str, + ): + # 根据组件 ID 获取对应的资源 ID + component_id_to_resource_id = self._get_component_id_to_resource_id(component_ids) + if len(component_id_to_resource_id) != len(component_ids): + missing_component_ids = set(component_ids) - set(component_id_to_resource_id.keys()) + raise ValueError( + f"The gateway resources corresponding to the component were not found, missing components ids: {','.join(map(str, missing_component_ids))}." + "Please contact the administrator." + ) + + # 创建组件权限申请单 + esb_record = super().create_apply_record(bk_app_code, system, component_ids, reason, expire_days, username) + + # 创建网关 bk-esb 的权限申请单 + manager = PermissionDimensionManager.get_manager(GrantDimensionEnum.RESOURCE.value) + gateway_record = manager.create_apply_record( + bk_app_code, + get_esb_gateway(), + list(component_id_to_resource_id.values()), + GrantDimensionEnum.RESOURCE.value, + reason, + expire_days, + username, + ) + + # 将网关权限单ID,记录到组件权限申请单,方便查询组件权限单据时,根据网关权限单获取单据实际的审批结果 + esb_record.gateway_apply_record_id = gateway_record.id + esb_record.save() + + return esb_record + + def renew_permission(self, bk_app_code: str, component_ids: List[int], expire_days: int): + """权限续期""" + # 根据组件 ID 获取对应的资源 ID + component_id_to_resource_id = self._get_component_id_to_resource_id(component_ids) + if len(component_id_to_resource_id) != len(component_ids): + missing_component_ids = set(component_ids) - set(component_id_to_resource_id.keys()) + raise ValueError( + f"The gateway resources corresponding to the component were not found, missing component ids: {','.join(map(str, missing_component_ids))}." + "Please contact the administrator." + ) + + # 续期组件权限 + super().renew_permission(bk_app_code, component_ids, expire_days) + + # 根据组件 ID 获取对应的资源 ID + component_id_to_resource_id = self._get_component_id_to_resource_id(component_ids) + + # 续期网关资源权限 + AppResourcePermission.objects.renew_by_resource_ids( + gateway=get_esb_gateway(), + bk_app_code=bk_app_code, + resource_ids=list(component_id_to_resource_id.values()), + grant_type=GrantTypeEnum.RENEW.value, + expire_days=expire_days, + ) + + def list_permissions(self, bk_app_code: str, system_id: Optional[int], components: List[Dict[str, Any]]): + """权限列表""" + component_ids = [component["id"] for component in components] + component_id_to_resource_id = self._get_component_id_to_resource_id(component_ids) + gateway = get_esb_gateway() + + resource_id_to_permission = { + perm.resource_id: AppComponentPermissionData( + expires_in=perm.expires_in, + ) + for perm in AppResourcePermission.objects.filter( + gateway=gateway, + bk_app_code=bk_app_code, + resource_id__in=component_id_to_resource_id.values(), + ) + } + + resource_id_to_apply_status = dict( + GatewayAppPermissionApplyStatus.objects.filter( + bk_app_code=bk_app_code, + gateway_id=gateway.id, + grant_dimension=GrantDimensionEnum.RESOURCE.value, + ).values_list("resource_id", "status") + ) + + components = copy.copy(components) + for component in components: + resource_id = component_id_to_resource_id.get(component["id"]) + component["component_permission"] = resource_id_to_permission.get(resource_id) + component["component_permission_apply_status"] = resource_id_to_apply_status.get(resource_id, None) + + component_permissions = parse_obj_as(List[ComponentPermission], components) + + return [perm.as_dict() for perm in component_permissions] + + def list_applied_permissions(self, bk_app_code: str, expire_days_range: Optional[int]): + """已申请权限的列表""" + gateway = get_esb_gateway() + + queryset = AppResourcePermission.objects.filter(gateway=gateway, bk_app_code=bk_app_code) + if expire_days_range is not None: + queryset = queryset.filter( + expires__range=(to_datetime_from_now(), to_datetime_from_now(expire_days_range)) + ) + resource_ids = list(queryset.values_list("resource_id", flat=True)) + + component_ids = list( + ComponentResourceBinding.objects.filter(resource_id__in=resource_ids).values_list( + "component_id", flat=True + ) + ) + + queryset = ESBChannel.objects.filter_active_and_public_components( + ids=component_ids, + allow_apply_permission=True, + ) + components = ESBChannel.objects.get_components(queryset) + + return self.list_permissions(bk_app_code, None, components) + + def patch_permission_apply_records(self, records: List[AppPermissionApplyRecord]): + gateway_apply_record_ids = [] + for record in records: + if not record.gateway_apply_record_id: + continue + gateway_apply_record_ids.append(record.gateway_apply_record_id) + + component_id_to_resource_id = self._get_component_id_to_resource_id() + resource_id_to_component_id = {value: key for key, value in component_id_to_resource_id.items()} + + gateway_apply_records = { + record.id: record for record in GatewayAppPermissionRecord.objects.filter(id__in=gateway_apply_record_ids) + } + for record in records: + if not record.gateway_apply_record_id: + continue + + gateway_record = gateway_apply_records.get(record.gateway_apply_record_id) + if not gateway_record: + continue + + record.status = gateway_record.status + record.comment = gateway_record.comment + record.handled_by = gateway_record.handled_by + record.handled_time = gateway_record.handled_time + record.handled_component_ids = { + status: [ + resource_id_to_component_id.get(resource_id) + for resource_id in resource_ids + if resource_id_to_component_id.get(resource_id) + ] + for status, resource_ids in gateway_record.handled_resource_ids.items() + } + + def _get_component_id_to_resource_id(self, component_ids: Optional[List[int]] = None) -> Dict[int, int]: + queryset = ComponentResourceBinding.objects.all() + + if component_ids is not None: + queryset = queryset.filter(component_id__in=component_ids) + + return dict(queryset.values_list("component_id", "resource_id")) + + +class AppComponentPermissionData(BaseModel): + expires_in: Optional[int] + + +class ComponentPermission(BaseModel): + class Config: + arbitrary_types_allowed = True + keep_untouched = (cached_property,) + + id: int + board: str + name: str + description: str + description_en: Optional[str] = None + system_name: str + permission_level: str + component_permission: Optional[AppComponentPermissionData] + component_permission_apply_status: Optional[str] + + def as_dict(self): + return { + "board": self.board, + "id": self.id, + "name": self.name, + "system_name": self.system_name, + "description": self.description, + "description_en": self.description_en, + "permission_level": self.permission_level, + "permission_status": self.permission_status, + "expires_in": self.expires_in, + "doc_link": self.doc_link, + } + + @property + def component_perm_required(self) -> bool: + return self.permission_level != PermissionLevelEnum.UNLIMITED.value + + @property + def doc_link(self): + return get_component_doc_link( + board=self.board, + system_name=self.system_name, + component_name=self.name, + ) + + @property + def permission_status(self) -> str: + # 如果组件不需要权限校验,则权限类型为:无限制,即默认拥有权限 + if not self.component_perm_required: + return PermissionStatusEnum.UNLIMITED.value + + # 如果权限记录中,有效期为永久有效;权限不需要再申请,优先展示 + if self.expires_in == math.inf: + return PermissionStatusEnum.OWNED.value + + # 如果权限已有申请状态,如已拒绝、申请中;优先展示 + if self.component_permission_apply_status: + return self.component_permission_apply_status + + # 有权限且未过期 + if self.expires_in > 0: + return PermissionStatusEnum.OWNED.value + + # 有权限,但是已过期 + if self.expires_in > -math.inf: + return PermissionStatusEnum.EXPIRED.value + + # 无权限,待申请 + return PermissionStatusEnum.NEED_APPLY.value + + @cached_property + def expires_in(self) -> Union[int, float]: + if not self.component_perm_required: + return math.inf + + return self._get_component_permission_expires_in() + + def _get_component_permission_expires_in(self) -> Union[int, float]: + if not self.component_permission: + return -math.inf + + return self._normalize_expires_in(self.component_permission.expires_in) + + def _normalize_expires_in(self, expires_in) -> Union[int, float]: + # 指定的过期时间为None,表示不过期,过期时间设置为 math.inf + if expires_in is None: + return math.inf + + return expires_in diff --git a/src/dashboard/apigateway/apigateway/biz/permission.py b/src/dashboard/apigateway/apigateway/biz/permission.py index df25e839f..801c44d9b 100644 --- a/src/dashboard/apigateway/apigateway/biz/permission.py +++ b/src/dashboard/apigateway/apigateway/biz/permission.py @@ -36,6 +36,7 @@ ) from apigateway.common.error_codes import error_codes from apigateway.core.models import Gateway, Resource +from apigateway.utils.time import now_datetime class ResourcePermissionHandler: @@ -107,6 +108,51 @@ def get_rejected_resource_names_display(self, gateway_id: int, resource_ids: Lis def allow_apply_permission(self, gateway_id: int, bk_app_code: str) -> Tuple[bool, str]: """判断是否允许申请权限""" + def create_apply_record( + self, + bk_app_code: str, + gateway: Gateway, + resource_ids: List[int], + grant_dimension: str, + reason: str, + expire_days: int, + username: str, + ) -> AppPermissionApply: + """创建申请权限的单据""" + record = AppPermissionRecord.objects.create( + bk_app_code=bk_app_code, + applied_by=username, + applied_time=now_datetime(), + reason=reason, + expire_days=expire_days, + gateway=gateway, + resource_ids=resource_ids, + grant_dimension=grant_dimension, + status=ApplyStatusEnum.PENDING.value, + ) + + instance = AppPermissionApply.objects.create( + bk_app_code=bk_app_code, + applied_by=username, + gateway=gateway, + resource_ids=resource_ids, + grant_dimension=grant_dimension, + status=ApplyStatusEnum.PENDING.value, + reason=reason, + expire_days=expire_days, + apply_record_id=record.id, + ) + + self.save_permission_apply_status( + bk_app_code=bk_app_code, + gateway=gateway, + apply=instance, + status=ApplyStatusEnum.PENDING.value, + resources=Resource.objects.filter(gateway=gateway, id__in=resource_ids), + ) + + return record + class APIPermissionDimensionManager(PermissionDimensionManager): def handle_permission_apply( diff --git a/src/dashboard/apigateway/apigateway/conf/default.py b/src/dashboard/apigateway/apigateway/conf/default.py index 1122df1af..611376cd8 100644 --- a/src/dashboard/apigateway/apigateway/conf/default.py +++ b/src/dashboard/apigateway/apigateway/conf/default.py @@ -761,6 +761,9 @@ # so we do a special process for them LEGACY_INVALID_PARAMS_GATEWAY_NAMES = env.list("LEGACY_INVALID_PARAMS_GATEWAY_NAMES", default=[]) +# 使用网关 bk-esb 管理组件 API 的权限 +USE_GATEWAY_BK_ESB_MANAGE_COMPONENT_PERMISSIONS = env.bool("USE_GATEWAY_BK_ESB_MANAGE_COMPONENT_PERMISSIONS", True) + # ============================================================================== # OTEL # ============================================================================== diff --git a/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/managers.py b/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/managers.py index 303bad27a..8613ad9d9 100644 --- a/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/managers.py +++ b/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/managers.py @@ -345,9 +345,11 @@ def renew_permissions( component_ids: List[int], expire_days: int, ): - self.filter(bk_app_code=bk_app_code, component_id__in=component_ids).update( - expires=to_datetime_from_now(days=expire_days), - ) + queryset = self.filter(bk_app_code=bk_app_code, component_id__in=component_ids) + # 仅续期权限期限小于待续期时间的权限 + expires = to_datetime_from_now(days=expire_days) + queryset = queryset.filter(expires__lt=expires) + queryset.update(expires=expires) def renew_permission_by_ids(self, ids: List[int], expire_days: int): self.filter(id__in=ids).update( @@ -438,7 +440,7 @@ def create_record( expire_days=expire_days, ) - def get_component_permisson_status( + def get_component_permission_status( self, bk_app_code: str, system_id: Optional[int], diff --git a/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/migrations/0014_apppermissionapplyrecord_gateway_apply_record_id.py b/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/migrations/0014_apppermissionapplyrecord_gateway_apply_record_id.py new file mode 100644 index 000000000..c56159f48 --- /dev/null +++ b/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/migrations/0014_apppermissionapplyrecord_gateway_apply_record_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-12-11 06:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bkcore', '0013_auto_20220923_1004'), + ] + + operations = [ + migrations.AddField( + model_name='apppermissionapplyrecord', + name='gateway_apply_record_id', + field=models.IntegerField(blank=True, help_text='网关 bk-esb 权限申请单ID,用于关联申请单,以获取网关申请单状态', null=True), + ), + ] \ No newline at end of file diff --git a/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/models.py b/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/models.py index 07374c913..c4c869e17 100644 --- a/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/models.py +++ b/src/dashboard/apigateway/apigateway/editions/ee/apps/esb/bkcore/models.py @@ -207,6 +207,11 @@ class AppPermissionApplyRecord(ModelWithBoard, TimestampedModelMixin): handled_component_ids = JSONField(default=dict, dump_kwargs={"indent": None}, blank=True) status = models.CharField(max_length=16, choices=ApplyStatusEnum.get_choices(), db_index=True) comment = models.CharField(max_length=512, blank=True, default="") + gateway_apply_record_id = models.IntegerField( + null=True, + blank=True, + help_text="网关 bk-esb 权限申请单ID,用于关联申请单,以获取网关申请单状态", + ) objects = managers.AppPermissionApplyRecordManager() diff --git a/src/dashboard/apigateway/apigateway/editions/ee/tests/apps/esb/bkcore/test_managers.py b/src/dashboard/apigateway/apigateway/editions/ee/tests/apps/esb/bkcore/test_managers.py new file mode 100644 index 000000000..f4dc1d1bd --- /dev/null +++ b/src/dashboard/apigateway/apigateway/editions/ee/tests/apps/esb/bkcore/test_managers.py @@ -0,0 +1,39 @@ +# +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +# +from ddf import G + +from apigateway.apps.esb.bkcore.models import AppComponentPermission +from apigateway.utils.time import to_datetime_from_now + + +class TestAppComponentPermissionManager: + def test_renew_permissions(self, unique_id): + perm = G( + AppComponentPermission, + bk_app_code=unique_id, + component_id=1, + expires=to_datetime_from_now(days=100), + ) + + AppComponentPermission.objects.renew_permissions(unique_id, [1], 70) + perm.refresh_from_db() + assert to_datetime_from_now(days=99) < perm.expires < to_datetime_from_now(days=101) + + AppComponentPermission.objects.renew_permissions(unique_id, [1], 170) + perm.refresh_from_db() + assert to_datetime_from_now(days=160) < perm.expires < to_datetime_from_now(days=180) diff --git a/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_helpers.py b/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_helpers.py deleted file mode 100644 index a38b5dcc4..000000000 --- a/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_helpers.py +++ /dev/null @@ -1,263 +0,0 @@ -# -*- coding: utf-8 -*- -# -# TencentBlueKing is pleased to support the open source community by making -# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available. -# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. -# Licensed under the MIT License (the "License"); you may not use this file except -# in compliance with the License. You may obtain a copy of the License at -# -# http://opensource.org/licenses/MIT -# -# Unless required by applicable law or agreed to in writing, software distributed under -# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, -# either express or implied. See the License for the specific language governing permissions and -# limitations under the License. -# -# We undertake not to change the open source license (MIT license) applicable -# to the current version of the project delivered to anyone in the future. -# -import math -from unittest import mock - -import pytest -from ddf import G - -from apigateway.apis.open.esb.permission.helpers import ComponentPermission, ComponentPermissionBuilder -from apigateway.apps.esb.bkcore.models import AppComponentPermission, ComponentSystem, ESBChannel - -pytestmark = pytest.mark.django_db - - -class TestComponentPermission: - @pytest.fixture - def mocked_component(self): - return { - "id": 1, - "board": "test", - "name": "test", - "description": "test", - "system_name": "test", - "permission_level": "normal", - "component_permission": None, - "component_permission_apply_status": "", - } - - @pytest.mark.parametrize( - "component, expected", - [ - ( - { - "id": 1, - "board": "test", - "name": "test", - "description": "test", - "system_name": "test", - "permission_level": "normal", - "component_permission": None, - "component_permission_apply_status": "", - }, - { - "id": 1, - "board": "test", - "name": "test", - "description": "test", - "description_en": None, - "system_name": "test", - "permission_level": "normal", - "permission_status": "need_apply", - "expires_in": -math.inf, - "doc_link": "", - }, - ), - ], - ) - def test_as_dict(self, mocker, component, expected): - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.get_component_doc_link", - return_value="", - ) - perm = ComponentPermission.parse_obj(component) - assert perm.as_dict() == expected - - @pytest.mark.parametrize( - "permission_level, expected", - [ - ("unlimited", False), - ("normal", True), - ("sensitive", True), - ("special", True), - ], - ) - def test_component_perm_required(self, mocked_component, permission_level, expected): - mocked_component["permission_level"] = permission_level - - perm = ComponentPermission.parse_obj(mocked_component) - assert perm.component_perm_required == expected - - @pytest.mark.parametrize( - "params, expected", - [ - ( - { - "component_perm_required": False, - }, - "unlimited", - ), - ( - { - "component_perm_required": True, - "expires_in": math.inf, - }, - "owned", - ), - ( - { - "component_perm_required": True, - "expires_in": 0, - "component_permission_apply_status": "pending", - }, - "pending", - ), - ( - { - "component_perm_required": True, - "expires_in": 10, - "component_permission_apply_status": "", - }, - "owned", - ), - ( - { - "component_perm_required": True, - "expires_in": -10, - "component_permission_apply_status": "", - }, - "expired", - ), - ( - { - "component_perm_required": True, - "expires_in": -math.inf, - "component_permission_apply_status": "", - }, - "need_apply", - ), - ], - ) - def test_permission_status(self, mocker, mocked_component, params, expected): - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.ComponentPermission.component_perm_required", - new_callable=mock.PropertyMock(return_value=params["component_perm_required"]), - ) - if "expires_in" in params: - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.ComponentPermission.expires_in", - new_callable=mock.PropertyMock(return_value=params["expires_in"]), - ) - - mocked_component.update(params) - - perm = ComponentPermission.parse_obj(mocked_component) - assert perm.permission_status == expected - - @pytest.mark.parametrize( - "params, expected", - [ - ( - { - "component_perm_required": False, - }, - math.inf, - ), - ( - { - "component_perm_required": True, - }, - -math.inf, - ), - ( - { - "component_perm_required": True, - "component_permission_expires_in": None, - }, - math.inf, - ), - ( - { - "component_perm_required": True, - "component_permission_expires_in": 10, - }, - 10, - ), - ( - { - "component_perm_required": True, - "component_permission_expires_in": -10, - }, - -10, - ), - ], - ) - def test_expires_in(self, mocker, mocked_component, params, expected): - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.ComponentPermission.component_perm_required", - new_callable=mock.PropertyMock(return_value=params["component_perm_required"]), - ) - - if "component_permission_expires_in" in params: - params["component_permission"] = G(AppComponentPermission) - mocker.patch( - "apigateway.apps.esb.bkcore.models.AppComponentPermission.expires_in", - new_callable=mock.PropertyMock(return_value=params["component_permission_expires_in"]), - ) - - mocked_component.update(params) - - perm = ComponentPermission.parse_obj(mocked_component) - assert perm.expires_in == expected - - -class TestComponentPermissionBuilder: - def test_build(self, mocker): - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.get_component_doc_link", - return_value="", - ) - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.ComponentPermission.expires_in", - new_callable=mock.PropertyMock(return_value=-math.inf), - ) - mocker.patch( - "apigateway.apis.open.esb.permission.helpers.ComponentPermission.permission_status", - new_callable=mock.PropertyMock(return_value="need_apply"), - ) - system = G(ComponentSystem) - component = G(ESBChannel, system=system) - - components = [ - { - "id": component.id, - "board": component.board or "", - "name": component.name, - "description": component.description, - "description_en": component.description_en, - "system_name": system.name, - "permission_level": component.permission_level, - } - ] - - result = ComponentPermissionBuilder(system.id, target_app_code="test").build(components) - assert result == [ - { - "id": component.id, - "board": component.board or "", - "name": component.name, - "description": component.description, - "description_en": component.description_en, - "system_name": system.name, - "permission_level": component.permission_level, - "permission_status": "need_apply", - "expires_in": -math.inf, - "doc_link": "", - } - ] diff --git a/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_views.py b/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_views.py index 65530769c..ec5093a58 100644 --- a/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_views.py +++ b/src/dashboard/apigateway/apigateway/tests/apis/open/esb/permission/test_views.py @@ -35,22 +35,26 @@ def test_list(self, mocker, request_factory): return_value="my-test", ) mocker.patch( - "apigateway.apis.open.esb.permission.views.ComponentPermissionBuilder.build", - return_value=[ - { - "id": 1, - "board": "test", - "name": "test", - "system_name": "test", - "description": "test", - "description_en": "test_en", - "expires_in": math.inf, - "permission_level": "normal", - "permission_status": "owned", - "doc_link": "", - "tag": "", + "apigateway.apis.open.esb.permission.views.ComponentPermissionManager.get_manager", + return_value=mocker.MagicMock( + **{ + "list_permissions.return_value": [ + { + "id": 1, + "board": "test", + "name": "test", + "system_name": "test", + "description": "test", + "description_en": "test_en", + "expires_in": math.inf, + "permission_level": "normal", + "permission_status": "owned", + "doc_link": "", + "tag": "", + } + ] } - ], + ), ) params = { @@ -82,15 +86,13 @@ def test_list(self, mocker, request_factory): class TestAppPermissionApplyV1APIView: - def test_apply(self, mocker, request_factory, unique_id): + def test_apply(self, settings, mocker, request_factory, unique_id): + settings.USE_GATEWAY_BK_ESB_MANAGE_COMPONENT_PERMISSIONS = False + mocker.patch( "apigateway.apis.open.esb.permission.serializers.BKAppCodeValidator.__call__", return_value=None, ) - mocker.patch( - "apigateway.apis.open.esb.permission.views.send_mail_for_perm_apply.apply_async", - return_value=None, - ) system = G(ComponentSystem) channel = G(ESBChannel, system=system) @@ -119,26 +121,26 @@ def test_list(self, mocker, request_factory): return_value=None, ) mocker.patch( - "apigateway.apis.open.esb.permission.views.AppComponentPermission.objects.filter_component_ids", - return_value=[1], - ) - mocker.patch( - "apigateway.apis.open.esb.permission.views.ComponentPermissionBuilder.build", - return_value=[ - { - "board": "test", - "id": 1, - "name": "test", - "system_name": "test", - "description": "desc", - "description_en": "desc_en", - "expires_in": 10, - "permission_level": "nomal", - "permission_status": "owned", - "permission_action": "", - "doc_link": "", - }, - ], + "apigateway.apis.open.esb.permission.views.ComponentPermissionManager.get_manager", + return_value=mocker.MagicMock( + **{ + "list_applied_permissions.return_value": [ + { + "board": "test", + "id": 1, + "name": "test", + "system_name": "test", + "description": "desc", + "description_en": "desc_en", + "expires_in": 10, + "permission_level": "nomal", + "permission_status": "owned", + "permission_action": "", + "doc_link": "", + }, + ], + } + ), ) mocker.patch( "apigateway.apis.open.esb.permission.serializers.BoardConfigManager.get_optional_display_label", @@ -189,7 +191,7 @@ def test_list(self, mocker, request_factory, unique_id): system = G(ComponentSystem, name=unique_id) - record = AppPermissionApplyRecord.objects.create_record( + AppPermissionApplyRecord.objects.create_record( board="test", bk_app_code=unique_id, applied_by="admin", diff --git a/src/dashboard/apigateway/apigateway/tests/biz/esb/conftest.py b/src/dashboard/apigateway/apigateway/tests/biz/esb/conftest.py new file mode 100644 index 000000000..bc5792dfd --- /dev/null +++ b/src/dashboard/apigateway/apigateway/tests/biz/esb/conftest.py @@ -0,0 +1,31 @@ +# +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +# +import pytest +from ddf import G + +from apigateway.apps.esb.bkcore.models import ComponentSystem, ESBChannel + + +@pytest.fixture +def fake_system(): + return G(ComponentSystem, board="default") + + +@pytest.fixture +def fake_channel(fake_system): + return G(ESBChannel, system=fake_system, board="default") diff --git a/src/dashboard/apigateway/apigateway/tests/biz/esb/test_component_resource_binding.py b/src/dashboard/apigateway/apigateway/tests/biz/esb/test_component_resource_binding.py index 6fb8f449b..29bcf1527 100644 --- a/src/dashboard/apigateway/apigateway/tests/biz/esb/test_component_resource_binding.py +++ b/src/dashboard/apigateway/apigateway/tests/biz/esb/test_component_resource_binding.py @@ -24,6 +24,9 @@ class TestComponentResourceBindingHandler: def test_sync(self, fake_resource_data): + if ComponentResourceBinding is None: + return + resource_1 = G(Resource) resource_2 = G(Resource) resource_3 = G(Resource) diff --git a/src/dashboard/apigateway/apigateway/tests/biz/esb/test_permissions.py b/src/dashboard/apigateway/apigateway/tests/biz/esb/test_permissions.py new file mode 100644 index 000000000..6347d82f1 --- /dev/null +++ b/src/dashboard/apigateway/apigateway/tests/biz/esb/test_permissions.py @@ -0,0 +1,565 @@ +# +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - API 网关(BlueKing - APIGateway) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific language governing permissions and +# limitations under the License. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +# +import json +import math + +import pytest +from ddf import G + +from apigateway.apps.esb.bkcore.models import ( + AppComponentPermission, + AppPermissionApplyRecord, + ComponentResourceBinding, +) +from apigateway.apps.permission.constants import ApplyStatusEnum, GrantDimensionEnum +from apigateway.apps.permission.models import AppPermissionRecord as GatewayAppPermissionRecord +from apigateway.apps.permission.models import AppResourcePermission +from apigateway.biz.esb.permissions import ( + AppComponentPermissionData, + ComponentPermission, + ComponentPermissionByEsbManager, + ComponentPermissionByGatewayManager, +) +from apigateway.utils.time import to_datetime_from_now + + +class TestComponentPermissionByEsbManager: + def test_create_apply_record(self, unique_id, fake_system, fake_channel): + manager = ComponentPermissionByEsbManager() + record = manager.create_apply_record( + bk_app_code=unique_id, + system=fake_system, + component_ids=[fake_channel.id], + reason="", + expire_days=180, + username="admin", + ) + + assert AppPermissionApplyRecord.objects.filter(id=record.id).exists() + + def test_renew_permission(self, unique_id, fake_channel): + perm = G( + AppComponentPermission, + bk_app_code=unique_id, + component_id=fake_channel.id, + expires=to_datetime_from_now(days=10), + ) + + manager = ComponentPermissionByEsbManager() + manager.renew_permission(unique_id, [fake_channel.id], 180) + + perm.refresh_from_db() + + assert to_datetime_from_now(days=170) < perm.expires < to_datetime_from_now(days=190) + + def test_list_permissions(self, mocker, fake_system, fake_channel): + mocker.patch( + "apigateway.biz.esb.permissions.get_component_doc_link", + return_value="", + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.expires_in", + new_callable=mocker.PropertyMock(return_value=-math.inf), + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.permission_status", + new_callable=mocker.PropertyMock(return_value="need_apply"), + ) + + components = [ + { + "id": fake_channel.id, + "board": fake_channel.board or "", + "name": fake_channel.name, + "description": fake_channel.description, + "description_en": fake_channel.description_en, + "system_name": fake_system.name, + "permission_level": fake_channel.permission_level, + } + ] + + manager = ComponentPermissionByEsbManager() + result = manager.list_permissions("test", fake_system.id, components) + assert result == [ + { + "id": fake_channel.id, + "board": fake_channel.board or "", + "name": fake_channel.name, + "description": fake_channel.description, + "description_en": fake_channel.description_en, + "system_name": fake_system.name, + "permission_level": fake_channel.permission_level, + "permission_status": "need_apply", + "expires_in": -math.inf, + "doc_link": "", + } + ] + + def test_list_applied_permissions(self, mocker, unique_id, fake_system, fake_channel): + mocker.patch( + "apigateway.biz.esb.permissions.get_component_doc_link", + return_value="", + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.expires_in", + new_callable=mocker.PropertyMock(return_value=-math.inf), + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.permission_status", + new_callable=mocker.PropertyMock(return_value="need_apply"), + ) + + G( + AppComponentPermission, + bk_app_code=unique_id, + component_id=fake_channel.id, + ) + + manager = ComponentPermissionByEsbManager() + result = manager.list_applied_permissions(unique_id, None) + assert result == [ + { + "id": fake_channel.id, + "board": fake_channel.board or "", + "name": fake_channel.name, + "description": fake_channel.description, + "description_en": fake_channel.description_en, + "system_name": fake_system.name, + "permission_level": fake_channel.permission_level, + "permission_status": "need_apply", + "expires_in": -math.inf, + "doc_link": "", + } + ] + + +class TestComponentPermissionByGatewayManager: + def test_create_apply_record(self, mocker, unique_id, fake_system, fake_channel, fake_gateway, fake_resource): + if ComponentResourceBinding is None: + return + + mocker.patch( + "apigateway.biz.esb.permissions.get_esb_gateway", + return_value=fake_gateway, + ) + G( + ComponentResourceBinding, + component_id=fake_channel.id, + resource_id=fake_resource.id, + ) + manager = ComponentPermissionByGatewayManager() + record = manager.create_apply_record( + bk_app_code=unique_id, + system=fake_system, + component_ids=[fake_channel.id], + reason="", + expire_days=180, + username="admin", + ) + + assert AppPermissionApplyRecord.objects.filter(id=record.id).exists() + assert GatewayAppPermissionRecord.objects.filter(id=record.gateway_apply_record_id).exists() + + def test_renew_permission(self, mocker, unique_id, fake_channel, fake_gateway, fake_resource): + if ComponentResourceBinding is None: + return + + mocker.patch( + "apigateway.biz.esb.permissions.get_esb_gateway", + return_value=fake_gateway, + ) + G( + ComponentResourceBinding, + component_id=fake_channel.id, + resource_id=fake_resource.id, + ) + perm1 = G( + AppComponentPermission, + bk_app_code=unique_id, + component_id=fake_channel.id, + expires=to_datetime_from_now(days=10), + ) + perm2 = G( + AppResourcePermission, + bk_app_code=unique_id, + gateway=fake_gateway, + resource_id=fake_resource.id, + expires=to_datetime_from_now(days=10), + grant_type="renew", + ) + + manager = ComponentPermissionByGatewayManager() + manager.renew_permission(unique_id, [fake_channel.id], 180) + + perm1.refresh_from_db() + perm2.refresh_from_db() + + assert to_datetime_from_now(days=170) < perm1.expires < to_datetime_from_now(days=190) + assert to_datetime_from_now(days=170) < perm2.expires < to_datetime_from_now(days=190) + + def list_permissions(self, mocker, unique_id, fake_system, fake_channel, fake_gateway, fake_resource): + if ComponentResourceBinding is None: + return + + mocker.patch( + "apigateway.biz.esb.permissions.get_esb_gateway", + return_value=fake_gateway, + ) + mocker.patch( + "apigateway.biz.esb.permissions.get_component_doc_link", + return_value="", + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.expires_in", + new_callable=mocker.PropertyMock(return_value=-math.inf), + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.permission_status", + new_callable=mocker.PropertyMock(return_value="need_apply"), + ) + G( + ComponentResourceBinding, + component_id=fake_channel.id, + resource_id=fake_resource.id, + ) + G( + AppResourcePermission, + bk_app_code=unique_id, + gateway=fake_gateway, + resource_id=fake_resource.id, + expires=to_datetime_from_now(days=10), + ) + + components = [ + { + "id": fake_channel.id, + "board": fake_channel.board or "", + "name": fake_channel.name, + "description": fake_channel.description, + "description_en": fake_channel.description_en, + "system_name": fake_system.name, + "permission_level": fake_channel.permission_level, + } + ] + + manager = ComponentPermissionByGatewayManager() + result = manager.list_permissions(unique_id, fake_system.id, components) + assert result == [ + { + "id": fake_channel.id, + "board": fake_channel.board or "", + "name": fake_channel.name, + "description": fake_channel.description, + "description_en": fake_channel.description_en, + "system_name": fake_system.name, + "permission_level": fake_channel.permission_level, + "permission_status": "owned", + "expires_in": -math.inf, + "doc_link": "", + } + ] + + def test_list_applied_permissions(self, mocker, unique_id, fake_system, fake_channel, fake_gateway, fake_resource): + if ComponentResourceBinding is None: + return + + mocker.patch( + "apigateway.biz.esb.permissions.get_esb_gateway", + return_value=fake_gateway, + ) + mocker.patch( + "apigateway.biz.esb.permissions.get_component_doc_link", + return_value="", + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.expires_in", + new_callable=mocker.PropertyMock(return_value=-math.inf), + ) + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.permission_status", + new_callable=mocker.PropertyMock(return_value="need_apply"), + ) + + G( + ComponentResourceBinding, + component_id=fake_channel.id, + resource_id=fake_resource.id, + ) + + G( + AppResourcePermission, + bk_app_code=unique_id, + gateway=fake_gateway, + resource_id=fake_resource.id, + ) + + manager = ComponentPermissionByGatewayManager() + result = manager.list_applied_permissions(unique_id, None) + assert result == [ + { + "id": fake_channel.id, + "board": fake_channel.board or "", + "name": fake_channel.name, + "description": fake_channel.description, + "description_en": fake_channel.description_en, + "system_name": fake_system.name, + "permission_level": fake_channel.permission_level, + "permission_status": "need_apply", + "expires_in": -math.inf, + "doc_link": "", + } + ] + + def test_patch_permission_apply_records(self, mocker, unique_id, fake_channel, fake_gateway, fake_resource): + if ComponentResourceBinding is None: + return + + mocker.patch( + "apigateway.biz.esb.permissions.get_esb_gateway", + return_value=fake_gateway, + ) + G( + ComponentResourceBinding, + component_id=fake_channel.id, + resource_id=fake_resource.id, + ) + + gateway_record = G( + GatewayAppPermissionRecord, + bk_app_code=unique_id, + gateway=fake_gateway, + _resource_ids=f"{fake_resource.id}", + _handled_resource_ids=json.dumps({"approved": [fake_resource.id], "rejected": []}), + grant_dimension=GrantDimensionEnum.RESOURCE.value, + status=ApplyStatusEnum.APPROVED.value, + ) + esb_record = G( + AppPermissionApplyRecord, + bk_app_code=unique_id, + _component_ids=f"{fake_channel.id}", + handled_component_ids={}, + status=ApplyStatusEnum.PENDING.value, + gateway_apply_record_id=gateway_record.id, + ) + + manager = ComponentPermissionByGatewayManager() + manager.patch_permission_apply_records([esb_record]) + + assert esb_record.status == gateway_record.status + assert esb_record.handled_by == gateway_record.handled_by + assert esb_record.handled_time == gateway_record.handled_time + assert esb_record.handled_component_ids == {"approved": [fake_channel.id], "rejected": []} + + def test_get_component_id_to_resource_id(self, fake_channel, faker): + if not ComponentResourceBinding: + return + + resource_id = faker.pyint() + + G(ComponentResourceBinding, component_id=fake_channel.id, resource_id=resource_id) + + manager = ComponentPermissionByGatewayManager() + result = manager._get_component_id_to_resource_id([fake_channel.id]) + assert result == {fake_channel.id: resource_id} + + +class TestComponentPermission: + @pytest.fixture + def mocked_component(self): + return { + "id": 1, + "board": "test", + "name": "test", + "description": "test", + "system_name": "test", + "permission_level": "normal", + "component_permission": None, + "component_permission_apply_status": "", + } + + @pytest.mark.parametrize( + "component, expected", + [ + ( + { + "id": 1, + "board": "test", + "name": "test", + "description": "test", + "system_name": "test", + "permission_level": "normal", + "component_permission": None, + "component_permission_apply_status": "", + }, + { + "id": 1, + "board": "test", + "name": "test", + "description": "test", + "description_en": None, + "system_name": "test", + "permission_level": "normal", + "permission_status": "need_apply", + "expires_in": -math.inf, + "doc_link": "", + }, + ), + ], + ) + def test_as_dict(self, mocker, component, expected): + mocker.patch( + "apigateway.biz.esb.permissions.get_component_doc_link", + return_value="", + ) + perm = ComponentPermission.parse_obj(component) + assert perm.as_dict() == expected + + @pytest.mark.parametrize( + "permission_level, expected", + [ + ("unlimited", False), + ("normal", True), + ("sensitive", True), + ("special", True), + ], + ) + def test_component_perm_required(self, mocked_component, permission_level, expected): + mocked_component["permission_level"] = permission_level + + perm = ComponentPermission.parse_obj(mocked_component) + assert perm.component_perm_required == expected + + @pytest.mark.parametrize( + "params, expected", + [ + ( + { + "component_perm_required": False, + }, + "unlimited", + ), + ( + { + "component_perm_required": True, + "expires_in": math.inf, + }, + "owned", + ), + ( + { + "component_perm_required": True, + "expires_in": 0, + "component_permission_apply_status": "pending", + }, + "pending", + ), + ( + { + "component_perm_required": True, + "expires_in": 10, + "component_permission_apply_status": "", + }, + "owned", + ), + ( + { + "component_perm_required": True, + "expires_in": -10, + "component_permission_apply_status": "", + }, + "expired", + ), + ( + { + "component_perm_required": True, + "expires_in": -math.inf, + "component_permission_apply_status": "", + }, + "need_apply", + ), + ], + ) + def test_permission_status(self, mocker, mocked_component, params, expected): + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.component_perm_required", + new_callable=mocker.PropertyMock(return_value=params["component_perm_required"]), + ) + if "expires_in" in params: + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.expires_in", + new_callable=mocker.PropertyMock(return_value=params["expires_in"]), + ) + + mocked_component.update(params) + + perm = ComponentPermission.parse_obj(mocked_component) + assert perm.permission_status == expected + + @pytest.mark.parametrize( + "params, expected", + [ + ( + { + "component_perm_required": False, + }, + math.inf, + ), + ( + { + "component_perm_required": True, + }, + -math.inf, + ), + ( + { + "component_perm_required": True, + "component_permission_expires_in": None, + }, + math.inf, + ), + ( + { + "component_perm_required": True, + "component_permission_expires_in": 10, + }, + 10, + ), + ( + { + "component_perm_required": True, + "component_permission_expires_in": -10, + }, + -10, + ), + ], + ) + def test_expires_in(self, mocker, mocked_component, params, expected): + mocker.patch( + "apigateway.biz.esb.permissions.ComponentPermission.component_perm_required", + new_callable=mocker.PropertyMock(return_value=params["component_perm_required"]), + ) + + if "component_permission_expires_in" in params: + params["component_permission"] = AppComponentPermissionData( + expires_in=params["component_permission_expires_in"] + ) + + mocked_component.update(params) + + perm = ComponentPermission.parse_obj(mocked_component) + assert perm.expires_in == expected diff --git a/src/dashboard/apigateway/apigateway/tests/biz/test_permission.py b/src/dashboard/apigateway/apigateway/tests/biz/test_permission.py index 4f0087366..605f4ac92 100644 --- a/src/dashboard/apigateway/apigateway/tests/biz/test_permission.py +++ b/src/dashboard/apigateway/apigateway/tests/biz/test_permission.py @@ -26,6 +26,7 @@ AppGatewayPermission, AppPermissionApply, AppPermissionApplyStatus, + AppPermissionRecord, AppResourcePermission, ) from apigateway.biz.permission import ( @@ -203,6 +204,22 @@ def test_allow_apply_permission(self, mocker, fake_gateway): result, _ = manager.allow_apply_permission(fake_gateway, target_app_code) assert result is True + def test_create_apply_record(self, fake_gateway, fake_resource): + manager = APIPermissionDimensionManager() + record = manager.create_apply_record( + "test", + fake_gateway, + [fake_resource.id], + GrantDimensionEnum.API.value, + "", + 180, + "admin", + ) + + assert AppPermissionRecord.objects.filter(id=record.id).exists() + assert AppPermissionApply.objects.filter(apply_record_id=record.id).exists() + assert AppPermissionApplyStatus.objects.filter(gateway=fake_gateway).exists() + class TestResourcePermissionDimensionManager: def _make_fake_apply(self, fake_gateway): @@ -338,3 +355,19 @@ def test_allow_apply_permission(self, fake_gateway): manager = ResourcePermissionDimensionManager() result, _ = manager.allow_apply_permission(fake_gateway.id, "test") assert not result + + def test_create_apply_record(self, fake_gateway, fake_resource): + manager = ResourcePermissionDimensionManager() + record = manager.create_apply_record( + "test", + fake_gateway, + [fake_resource.id], + GrantDimensionEnum.API.value, + "", + 180, + "admin", + ) + + assert AppPermissionRecord.objects.filter(id=record.id).exists() + assert AppPermissionApply.objects.filter(apply_record_id=record.id).exists() + assert AppPermissionApplyStatus.objects.filter(gateway=fake_gateway).exists()