diff --git a/.github/workflows/bk-login.yml b/.github/workflows/bk-login.yml index 663e0ba89..b01cf80c3 100644 --- a/.github/workflows/bk-login.yml +++ b/.github/workflows/bk-login.yml @@ -26,8 +26,8 @@ jobs: - name: Format & Lint with ruff run: | pip install ruff==0.1.4 - ruff format src/bk-login --config=src/bk-login/pyproject.toml - ruff src/bk-login --config=src/bk-login/pyproject.toml + ruff format src/bk-login --config=src/bk-login/pyproject.toml --no-cache + ruff src/bk-login --config=src/bk-login/pyproject.toml --no-cache - name: Lint with mypy run: | pip install mypy==1.6.1 types-requests==2.31.0.2 types-setuptools==57.4.18 types-dataclasses==0.1.7 types-redis==3.5.18 types-PyMySQL==1.1.0.1 types-six==0.1.9 types-toml==0.1.5 types-pytz==2023.3.0.0 types-urllib3==1.26.25.14 diff --git a/.github/workflows/bk-user.yml b/.github/workflows/bk-user.yml index cac608199..f3641e8b7 100644 --- a/.github/workflows/bk-user.yml +++ b/.github/workflows/bk-user.yml @@ -26,8 +26,8 @@ jobs: - name: Format & Lint with ruff run: | pip install ruff==0.1.4 - ruff format src/bk-user --config=src/bk-user/pyproject.toml - ruff src/bk-user --config=src/bk-user/pyproject.toml + ruff format src/bk-user --config=src/bk-user/pyproject.toml --no-cache + ruff src/bk-user --config=src/bk-user/pyproject.toml --no-cache - name: Lint with mypy run: | pip install mypy==1.6.1 types-requests==2.31.0.2 types-setuptools==57.4.18 types-dataclasses==0.1.7 types-redis==3.5.18 types-PyMySQL==1.1.0.1 types-six==0.1.9 types-toml==0.1.5 types-pytz==2023.3.0.0 types-urllib3==1.26.25.14 diff --git a/.github/workflows/idp-plugins.yml b/.github/workflows/idp-plugins.yml index 8b6944b4f..976089054 100644 --- a/.github/workflows/idp-plugins.yml +++ b/.github/workflows/idp-plugins.yml @@ -21,8 +21,8 @@ jobs: - name: Format & Lint with ruff run: | pip install ruff==0.1.4 - ruff format src/idp-plugins --config=src/idp-plugins/pyproject.toml - ruff src/idp-plugins --config=src/idp-plugins/pyproject.toml + ruff format src/idp-plugins --config=src/idp-plugins/pyproject.toml --no-cache + ruff src/idp-plugins --config=src/idp-plugins/pyproject.toml --no-cache - name: Lint with mypy run: | pip install mypy==1.6.1 types-requests==2.31.0.2 types-setuptools==57.4.18 types-dataclasses==0.1.7 types-redis==3.5.18 types-PyMySQL==1.1.0.1 types-six==0.1.9 types-toml==0.1.5 types-pytz==2023.3.0.0 types-urllib3==1.26.25.14 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ce2058cf..f140e2306 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,13 +55,13 @@ repos: name: ruff-formatter language: python types: [python] - entry: ruff format --config=src/bk-user/pyproject.toml --force-exclude + entry: ruff format --config=src/bk-user/pyproject.toml --no-cache files: src/bk-user/ - id: ruff name: ruff language: python types: [python] - entry: ruff --config=src/bk-user/pyproject.toml --force-exclude --fix + entry: ruff --config=src/bk-user/pyproject.toml --fix --no-cache files: src/bk-user/ - id: mypy name: mypy @@ -80,13 +80,13 @@ repos: name: ruff-formatter language: python types: [python] - entry: ruff format --config=src/bk-login/pyproject.toml --force-exclude + entry: ruff format --config=src/bk-login/pyproject.toml --no-cache files: src/bk-login/ - id: ruff name: ruff language: python types: [python] - entry: ruff --config=src/bk-login/pyproject.toml --force-exclude --fix + entry: ruff --config=src/bk-login/pyproject.toml --fix --no-cache files: src/bk-login/ - id: mypy name: mypy @@ -105,13 +105,13 @@ repos: name: ruff-formatter language: python types: [python] - entry: ruff format --config=src/bk-plugins/pyproject.toml --force-exclude + entry: ruff format --config=src/bk-plugins/pyproject.toml --no-cache files: src/bk-plugins/ - id: ruff name: ruff language: python types: [python] - entry: ruff --config=src/idp-plugins/pyproject.toml --force-exclude --fix + entry: ruff --config=src/idp-plugins/pyproject.toml --fix --no-cache files: src/idp-plugins/ - id: mypy name: mypy diff --git a/src/bk-login/pyproject.toml b/src/bk-login/pyproject.toml index 30ff22b09..f6d31fdd9 100644 --- a/src/bk-login/pyproject.toml +++ b/src/bk-login/pyproject.toml @@ -109,6 +109,7 @@ exclude = [ "venv", "*/migrations/*", ] +force-exclude = true [tool.ruff.mccabe] # Unlike Flake8, default to a complexity level of 10. diff --git a/src/bk-user/bkuser/apis/login/serializers.py b/src/bk-user/bkuser/apis/login/serializers.py index 19891d21a..9170b87c4 100644 --- a/src/bk-user/bkuser/apis/login/serializers.py +++ b/src/bk-user/bkuser/apis/login/serializers.py @@ -48,6 +48,9 @@ class TenantListOutputSLZ(serializers.Serializer): name = serializers.CharField(help_text="租户名称") logo = serializers.CharField(help_text="租户 Logo") + class Meta: + ref_name = "login.TenantListOutputSLZ" + class TenantRetrieveOutputSLZ(TenantListOutputSLZ): ... @@ -57,6 +60,9 @@ class IdpPluginOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="认证源插件 ID") name = serializers.CharField(help_text="认证源插件名称") + class Meta: + ref_name = "login.IdpPluginOutputSLZ" + class IdpListOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="认证源 ID") @@ -69,6 +75,9 @@ class IdpRetrieveOutputSLZ(IdpListOutputSLZ): owner_tenant_id = serializers.CharField(help_text="归属的租户 ID") plugin_config = serializers.JSONField(help_text="认证源插件配置") + class Meta: + ref_name = "login.IdpRetrieveOutputSLZ" + class TenantUserMatchInputSLZ(serializers.Serializer): idp_users = serializers.ListField( @@ -92,3 +101,6 @@ class TenantUserRetrieveOutputSLZ(serializers.Serializer): time_zone = serializers.CharField(help_text="时区") tenant_id = serializers.CharField(help_text="用户所在租户 ID") + + class Meta: + ref_name = "login.TenantUserRetrieveOutputSLZ" diff --git a/src/bk-user/bkuser/apis/login/views.py b/src/bk-user/bkuser/apis/login/views.py index c25e4148d..cc68ad862 100644 --- a/src/bk-user/bkuser/apis/login/views.py +++ b/src/bk-user/bkuser/apis/login/views.py @@ -16,7 +16,7 @@ from rest_framework.response import Response from bkuser.apps.data_source.models import DataSourceUser, LocalDataSourceIdentityInfo -from bkuser.apps.idp.data_models import DataSourceMatchRuleList, convert_match_rules_to_queryset_filter +from bkuser.apps.idp.data_models import convert_match_rules_to_queryset_filter from bkuser.apps.idp.models import Idp from bkuser.apps.tenant.models import Tenant, TenantUser from bkuser.common.error_codes import error_codes @@ -134,13 +134,11 @@ def post(self, request, *args, **kwargs): # 一般社会化登录都得通过绑定匹配方式,比如QQ,用户得先绑定后才能使用QQ登录 # 直接匹配,一般是企业身份登录方式, # 比如企业内部SAML2.0登录,认证后获取到的用户字段,能直接与数据源里的用户数据字段匹配 - # 认证源与数据源的匹配规则 - data_source_match_rules = DataSourceMatchRuleList.validate_python(idp.data_source_match_rules) # 将规则转换为Django Queryset 过滤条件, 不同用户之间过滤逻辑是OR conditions = [ condition for userinfo in data["idp_users"] - if (condition := convert_match_rules_to_queryset_filter(data_source_match_rules, userinfo)) + if (condition := convert_match_rules_to_queryset_filter(idp.data_source_match_rule_objs, userinfo)) ] # 查询数据源用户 diff --git a/src/bk-user/bkuser/apis/web/idp/__init__.py b/src/bk-user/bkuser/apis/web/idp/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/idp/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" diff --git a/src/bk-user/bkuser/apis/web/idp/serializers.py b/src/bk-user/bkuser/apis/web/idp/serializers.py new file mode 100644 index 000000000..9f051dc9c --- /dev/null +++ b/src/bk-user/bkuser/apis/web/idp/serializers.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from typing import Any, Dict, List + +from django.utils.translation import gettext_lazy as _ +from drf_yasg.utils import swagger_serializer_method +from pydantic import ValidationError as PDValidationError +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.idp.constants import IdpStatus +from bkuser.apps.idp.models import Idp, IdpPlugin +from bkuser.apps.tenant.models import UserBuiltinField +from bkuser.idp_plugins.base import get_plugin_cfg_cls +from bkuser.idp_plugins.constants import BuiltinIdpPluginEnum +from bkuser.utils.pydantic import stringify_pydantic_error + + +class IdpPluginOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="认证源插件唯一标识") + name = serializers.CharField(help_text="认证源插件名称") + description = serializers.CharField(help_text="认证源插件描述") + logo = serializers.CharField(help_text="认证源插件 Logo") + + +class IdpSearchInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", required=False) + + +class IdpSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="认证源唯一标识") + name = serializers.CharField(help_text="认证源名称") + status = serializers.ChoiceField(help_text="认证源状态", choices=IdpStatus.get_choices()) + updater = serializers.CharField(help_text="更新者") + updated_at = serializers.CharField(help_text="更新时间", source="updated_at_display") + plugin = IdpPluginOutputSLZ(help_text="认证源插件") + matched_data_sources = serializers.SerializerMethodField(help_text="匹配的数据源列表") + + @swagger_serializer_method( + serializer_or_field=serializers.ListField( + help_text="匹配的数据源", + child=serializers.CharField(), + allow_empty=True, + ) + ) + def get_matched_data_sources(self, obj: Idp) -> List[str]: + data_source_name_map = self.context["data_source_name_map"] + + return [ + data_source_name_map[r.data_source_id] + for r in obj.data_source_match_rule_objs + if r.data_source_id in data_source_name_map + ] + + +def _validate_duplicate_idp_name(name: str, tenant_id: str, idp_id: str = "") -> str: + """校验IDP 是否重名""" + queryset = Idp.objects.filter(name=name, owner_tenant_id=tenant_id) + # 过滤掉自身名称 + if idp_id: + queryset = queryset.exclude(id=idp_id) + + if queryset.exists(): + raise ValidationError(_("同名认证源已存在")) + + return name + + +class FieldCompareRuleSLZ(serializers.Serializer): + source_field = serializers.CharField(help_text="认证源原始字段") + target_field = serializers.CharField(help_text="匹配的数据源字段") + + +class DataSourceMatchRuleSLZ(serializers.Serializer): + data_source_id = serializers.IntegerField(help_text="数据源 ID") + field_compare_rules = serializers.ListField( + help_text="字段比较规则", child=FieldCompareRuleSLZ(), allow_empty=False, min_length=1 + ) + + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + # 数据源是否当前租户的 + tenant_id = self.context["tenant_id"] + if not DataSource.objects.filter(id=attrs["data_source_id"], owner_tenant_id=tenant_id).exists(): + raise ValidationError(_("数据源必须是当前租户下的,{} 并不符合").format(attrs["data_source_id"])) + + # # 匹配的数据源字段必须是当前租户的用户字段,包括内建字段和自定义字段 + builtin_fields = set(UserBuiltinField.objects.all().values_list("name", flat=True)) + # custom_fields = set(TenantUserCustomField.objects.filter(tenant_id=tenant_id).values_list("name", flat=True)) + # allowed_target_fields = builtin_fields | custom_fields + # + target_fields = {r.get("target_field") for r in attrs["field_compare_rules"]} + # if not_found_fields := target_fields - allowed_target_fields: + # raise ValidationError(_("匹配的数据源字段 {} 不属于用户自定义字段或内置字段").format(not_found_fields)) + if not_found_fields := target_fields - builtin_fields: + raise ValidationError( + _("匹配的数据源字段 {} 不属于用户内置字段,当前仅支持匹配内置字段").format(not_found_fields) + ) + + return attrs + + +class IdpCreateInputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="认证源名称", max_length=128) + plugin_id = serializers.CharField(help_text="认证源插件 ID") + plugin_config = serializers.JSONField(help_text="认证源插件配置") + data_source_match_rules = serializers.ListField( + help_text="数据源匹配规则", child=DataSourceMatchRuleSLZ(), allow_empty=True, default=list + ) + + def validate_name(self, name: str) -> str: + return _validate_duplicate_idp_name(name, self.context["tenant_id"]) + + def validate_plugin_id(self, plugin_id: str) -> str: + if not IdpPlugin.objects.filter(id=plugin_id).exists(): + raise ValidationError(_("认证源插件不存在")) + + if plugin_id == BuiltinIdpPluginEnum.LOCAL: + raise ValidationError(_("不允许创建本地账密认证源")) + + return plugin_id + + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + plugin_id = attrs["plugin_id"] + + try: + cfg_cls = get_plugin_cfg_cls(plugin_id) + except NotImplementedError: + raise ValidationError(_("认证源插件 {} 不存在").format(plugin_id)) + + try: + attrs["plugin_config"] = cfg_cls(**attrs["plugin_config"]).model_dump() + except PDValidationError as e: + raise ValidationError(_("认证源插件配置不合法:{}").format(stringify_pydantic_error(e))) + + return attrs + + +class IdpCreateOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="认证源 ID") + callback_uri = serializers.CharField(help_text="回调地址") + + +class IdpRetrieveOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="认证源唯一标识") + name = serializers.CharField(help_text="认证源名称") + owner_tenant_id = serializers.CharField(help_text="认证源所属租户 ID") + status = serializers.ChoiceField(help_text="认证源状态", choices=IdpStatus.get_choices()) + plugin = IdpPluginOutputSLZ(help_text="认证源插件") + plugin_config = serializers.JSONField(help_text="认证源插件配置") + data_source_match_rules = serializers.JSONField(help_text="数据源匹配规则", default=list) + callback_uri = serializers.CharField(help_text="回调地址") + + +class IdpPartialUpdateInputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="认证源名称") + + def validate_name(self, name: str) -> str: + return _validate_duplicate_idp_name(name, self.context["tenant_id"], self.context["idp_id"]) + + +class IdpUpdateInputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="认证源名称", max_length=128) + plugin_config = serializers.JSONField(help_text="认证源插件配置") + data_source_match_rules = serializers.ListField( + help_text="数据源匹配规则", child=DataSourceMatchRuleSLZ(), allow_empty=True, default=list + ) + + def validate_name(self, name: str) -> str: + return _validate_duplicate_idp_name(name, self.context["tenant_id"], self.context["idp_id"]) + + def validate_plugin_config(self, plugin_config: Dict[str, Any]) -> Dict[str, Any]: + cfg_cls = get_plugin_cfg_cls(self.context["plugin_id"]) + + try: + return cfg_cls(**plugin_config).model_dump() + except PDValidationError as e: + raise ValidationError(_("认证源插件配置不合法:{}").format(stringify_pydantic_error(e))) diff --git a/src/bk-user/bkuser/apis/web/idp/swagger.py b/src/bk-user/bkuser/apis/web/idp/swagger.py new file mode 100644 index 000000000..c47dfc407 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/idp/swagger.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from typing import Dict + +from drf_yasg import openapi + +from bkuser.idp_plugins.base import list_plugin_cls +from bkuser.utils.pydantic import gen_openapi_schema + + +def get_idp_plugin_cfg_schema_map() -> Dict[str, openapi.Schema]: + """获取认证插件配置类 JsonSchema 映射表""" + return { + f"plugin_config:{plugin_cls.id}": gen_openapi_schema(plugin_cls.config_class) + for plugin_cls in list_plugin_cls() + } diff --git a/src/bk-user/bkuser/apis/web/idp/urls.py b/src/bk-user/bkuser/apis/web/idp/urls.py new file mode 100644 index 000000000..5ea870f6a --- /dev/null +++ b/src/bk-user/bkuser/apis/web/idp/urls.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from django.urls import path + +from . import views + +urlpatterns = [ + # 认证源插件列表 + path("plugins/", views.IdpPluginListApi.as_view(), name="idp_plugin.list"), + # 认证源创建/获取列表 + path("", views.IdpListCreateApi.as_view(), name="idp.list_create"), + # 认证源获取/更新 + path("/", views.IdpRetrieveUpdateApi.as_view(), name="idp.retrieve_update"), +] diff --git a/src/bk-user/bkuser/apis/web/idp/views.py b/src/bk-user/bkuser/apis/web/idp/views.py new file mode 100644 index 000000000..112155678 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/idp/views.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from django.db import transaction +from django.utils.translation import gettext_lazy as _ +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from bkuser.apis.web.mixins import CurrentUserTenantMixin +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.idp.models import Idp, IdpPlugin +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class +from bkuser.common.error_codes import error_codes + +from .serializers import ( + IdpCreateInputSLZ, + IdpCreateOutputSLZ, + IdpPartialUpdateInputSLZ, + IdpPluginOutputSLZ, + IdpRetrieveOutputSLZ, + IdpSearchInputSLZ, + IdpSearchOutputSLZ, + IdpUpdateInputSLZ, +) +from .swagger import get_idp_plugin_cfg_schema_map + + +class IdpPluginListApi(generics.ListAPIView): + queryset = IdpPlugin.objects.all() + pagination_class = None + serializer_class = IdpPluginOutputSLZ + + @swagger_auto_schema( + tags=["idp_plugin"], + operation_description="认证源插件列表", + responses={status.HTTP_200_OK: IdpPluginOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class IdpListCreateApi(CurrentUserTenantMixin, generics.ListCreateAPIView): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + pagination_class = None + serializer_class = IdpSearchOutputSLZ + + def get_serializer_context(self): + # TODO 目前未支持数据源跨租户协助,所以只查询本租户数据源 + data_source_name_map = dict( + DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()).values_list("id", "name") + ) + return {"data_source_name_map": data_source_name_map} + + def get_queryset(self): + slz = IdpSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + queryset = Idp.objects.filter(owner_tenant_id=self.get_current_tenant_id()) + if kw := data.get("keyword"): + queryset = queryset.filter(name__icontains=kw) + + # 关联查询插件 + queryset.select_related("plugin") + + return queryset + + @swagger_auto_schema( + tags=["idp"], + operation_description="认证源列表", + query_serializer=IdpSearchInputSLZ(), + responses={status.HTTP_200_OK: IdpSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["idp"], + operation_description="新建认证源", + request_body=IdpCreateInputSLZ(), + responses={status.HTTP_201_CREATED: IdpCreateOutputSLZ(), **get_idp_plugin_cfg_schema_map()}, + ) + def post(self, request, *args, **kwargs): + current_tenant_id = self.get_current_tenant_id() + slz = IdpCreateInputSLZ(data=request.data, context={"tenant_id": current_tenant_id}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + current_user = request.user.username + plugin = IdpPlugin.objects.get(id=data["plugin_id"]) + + with transaction.atomic(): + idp = Idp.objects.create( + name=data["name"], + owner_tenant_id=current_tenant_id, + plugin=plugin, + plugin_config=data["plugin_config"], + data_source_match_rules=data["data_source_match_rules"], + creator=current_user, + updater=current_user, + ) + + return Response(IdpCreateOutputSLZ(instance=idp).data, status=status.HTTP_201_CREATED) + + +class IdpRetrieveUpdateApi(CurrentUserTenantMixin, generics.RetrieveUpdateAPIView): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + serializer_class = IdpRetrieveOutputSLZ + lookup_url_kwarg = "id" + + def get_queryset(self): + return Idp.objects.filter(owner_tenant_id=self.get_current_tenant_id()) + + @swagger_auto_schema( + tags=["idp"], + operation_description="认证源详情", + responses={ + status.HTTP_200_OK: IdpRetrieveOutputSLZ(), + **get_idp_plugin_cfg_schema_map(), + }, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["idp"], + operation_description="更新认证源部分字段", + request_body=IdpPartialUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def patch(self, request, *args, **kwargs): + idp = self.get_object() + current_tenant_id = self.get_current_tenant_id() + slz = IdpPartialUpdateInputSLZ(data=request.data, context={"tenant_id": current_tenant_id, "idp_id": idp.id}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + idp.name = data["name"] + idp.updater = request.user.username + idp.save(update_fields=["name", "updater", "updated_at"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema( + tags=["idp"], + operation_description="更新认证源", + request_body=IdpUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: "", **get_idp_plugin_cfg_schema_map()}, + ) + def put(self, request, *args, **kwargs): + idp = self.get_object() + if idp.is_local: + raise error_codes.CANNOT_UPDATE_IDP.f(_("本地账密认证源不允许更新配置")) + + current_tenant_id = self.get_current_tenant_id() + slz = IdpUpdateInputSLZ( + data=request.data, + context={"tenant_id": current_tenant_id, "idp_id": idp.id, "plugin_id": idp.plugin_id}, + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + idp.name = data["name"] + idp.plugin_config = data["plugin_config"] + idp.data_source_match_rules = data["data_source_match_rules"] + idp.updater = request.user.username + idp.save(update_fields=["name", "plugin_config", "data_source_match_rules", "updater", "updated_at"]) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index 6543bde2b..2991a7f10 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -15,10 +15,15 @@ path("basic/", include("bkuser.apis.web.basic.urls")), # 租户 path("tenants/", include("bkuser.apis.web.tenant.urls")), + # 租户组织架构 path("tenant-organization/", include("bkuser.apis.web.organization.urls")), + # 数据源 & 数据源用户/部门 path("data-sources/", include("bkuser.apis.web.data_source.urls")), path("data-sources/", include("bkuser.apis.web.data_source_organization.urls")), + # 认证源 + path("idps/", include("bkuser.apis.web.idp.urls")), + # 租户配置 + path("tenant-setting/", include("bkuser.apis.web.tenant_setting.urls")), # 个人中心 path("personal-center/", include("bkuser.apis.web.personal_center.urls")), - path("tenant-setting/", include("bkuser.apis.web.tenant_setting.urls")), ] diff --git a/src/bk-user/bkuser/apps/idp/handlers.py b/src/bk-user/bkuser/apps/idp/handlers.py index 10e51fd6a..7d72673e6 100644 --- a/src/bk-user/bkuser/apps/idp/handlers.py +++ b/src/bk-user/bkuser/apps/idp/handlers.py @@ -20,7 +20,7 @@ from bkuser.plugins.local.models import LocalDataSourcePluginConfig from .constants import IdpStatus -from .data_models import DataSourceMatchRuleList, gen_data_source_match_rule_of_local +from .data_models import gen_data_source_match_rule_of_local from .models import Idp, IdpPlugin @@ -54,7 +54,7 @@ def _update_local_idp_of_tenant(data_source: DataSource): # 根据数据源是否使用账密登录,修改认证源配置 idp_plugin_cfg = LocalIdpPluginConfig(**idp.plugin_config) - data_source_match_rules = DataSourceMatchRuleList.validate_python(idp.data_source_match_rules) + data_source_match_rules = idp.data_source_match_rule_objs # 对于启用登录,则需要添加进配置 if enable_login and data_source.id not in idp_plugin_cfg.data_source_ids: diff --git a/src/bk-user/bkuser/apps/idp/models.py b/src/bk-user/bkuser/apps/idp/models.py index 8edf5e864..9d3472e7c 100644 --- a/src/bk-user/bkuser/apps/idp/models.py +++ b/src/bk-user/bkuser/apps/idp/models.py @@ -8,12 +8,19 @@ 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. """ +from typing import List +from urllib.parse import urljoin + +from django.conf import settings from django.db import models from bkuser.common.models import AuditedModel +from bkuser.idp_plugins.base import get_plugin_type +from bkuser.idp_plugins.constants import BuiltinIdpPluginEnum, PluginTypeEnum from bkuser.utils.uuid import generate_uuid from .constants import IdpStatus +from .data_models import DataSourceMatchRule, DataSourceMatchRuleList class IdpPlugin(models.Model): @@ -45,3 +52,22 @@ class Meta: unique_together = [ ("name", "owner_tenant_id"), ] + + @property + def is_local(self) -> bool: + """检查类型是否为本地账密认证源""" + return self.plugin.id == BuiltinIdpPluginEnum.LOCAL + + @property + def data_source_match_rule_objs(self) -> List[DataSourceMatchRule]: + """转换为规则对象列表""" + return DataSourceMatchRuleList.validate_python(self.data_source_match_rules) + + @property + def callback_uri(self) -> str: + plugin_type = get_plugin_type(self.plugin.id) + # 联邦登录才有回调地址 + if plugin_type == PluginTypeEnum.FEDERATION: + return urljoin(settings.BK_LOGIN_URL, f"auth/idps/{self.id}/actions/callback/") + + return "" diff --git a/src/bk-user/bkuser/apps/permission/permissions.py b/src/bk-user/bkuser/apps/permission/permissions.py index b599e24b9..beb21c56e 100644 --- a/src/bk-user/bkuser/apps/permission/permissions.py +++ b/src/bk-user/bkuser/apps/permission/permissions.py @@ -21,6 +21,7 @@ from rest_framework.permissions import BasePermission from bkuser.apps.data_source.models import DataSource +from bkuser.apps.idp.models import Idp from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation from bkuser.apps.permission.constants import PermAction, UserRole from bkuser.apps.tenant.models import Tenant, TenantManager, TenantUser @@ -57,6 +58,8 @@ def has_object_permission(self, request, view, obj): elif hasattr(obj, "data_source"): # TODO (su) 考虑数据源协同的情况 tenant_id = obj.data_source.owner_tenant_id + elif isinstance(obj, Idp): + tenant_id = obj.owner_tenant_id else: logger.exception("failed to get tenant id, obj: %s", obj) return False diff --git a/src/bk-user/bkuser/common/constants.py b/src/bk-user/bkuser/common/constants.py index 9752559c4..25aae6793 100644 --- a/src/bk-user/bkuser/common/constants.py +++ b/src/bk-user/bkuser/common/constants.py @@ -19,9 +19,7 @@ class BkLanguageEnum(str, StructuredEnum): # 永久:2100-01-01 00:00:00 UTC -PERMANENT_TIME = datetime.datetime( - year=2100, month=1, day=1, hour=0, minute=0, second=0, tzinfo=datetime.timezone.utc -) +PERMANENT_TIME = datetime.datetime(year=2100, month=1, day=1, hour=0, minute=0, second=0, tzinfo=datetime.timezone.utc) # 敏感信息掩码(7 位 * 是故意的,避免遇到用户输入 6/8 位 * 的情况) SENSITIVE_MASK = "*******" diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index 6770abeb0..27612e9d4 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -84,6 +84,9 @@ class ErrorCodes: DATA_SOURCE_IMPORT_FAILED = ErrorCode(_("数据源导入失败")) CREATE_DATA_SOURCE_SYNC_TASK_FAILED = ErrorCode(_("创建数据源同步任务失败")) + # 认证源 + CANNOT_UPDATE_IDP = ErrorCode(_("该认证源不允许更新配置")) + # 租户 CREATE_TENANT_FAILED = ErrorCode(_("租户创建失败")) UPDATE_TENANT_FAILED = ErrorCode(_("租户更新失败")) diff --git a/src/bk-user/pyproject.toml b/src/bk-user/pyproject.toml index 4af980417..d65755f4c 100644 --- a/src/bk-user/pyproject.toml +++ b/src/bk-user/pyproject.toml @@ -128,6 +128,7 @@ exclude = [ "venv", "*/migrations/*", ] +force-exclude = true [tool.ruff.mccabe] # Unlike Flake8, default to a complexity level of 10. diff --git a/src/bk-user/tests/apis/web/idp/__init__.py b/src/bk-user/tests/apis/web/idp/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apis/web/idp/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" diff --git a/src/bk-user/tests/apis/web/idp/test_idp.py b/src/bk-user/tests/apis/web/idp/test_idp.py new file mode 100644 index 000000000..5ed9226b8 --- /dev/null +++ b/src/bk-user/tests/apis/web/idp/test_idp.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from typing import Any, Dict, List + +import pytest +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.idp.models import Idp, IdpPlugin +from bkuser.idp_plugins.constants import BuiltinIdpPluginEnum +from django.urls import reverse +from rest_framework import status + +from tests.test_utils.helpers import generate_random_string + +pytestmark = pytest.mark.django_db + + +@pytest.fixture() +def default_data_source(default_tenant) -> DataSource: + default_data_source = DataSource.objects.filter(owner_tenant_id=default_tenant.id).first() + assert default_data_source is not None + return default_data_source + + +@pytest.fixture() +def default_idp(default_tenant) -> Idp: + default_idp = Idp.objects.filter(owner_tenant_id=default_tenant.id).first() + assert default_idp is not None + return default_idp + + +@pytest.fixture() +def wecom_plugin_cfg() -> Dict[str, Any]: + """企业微信插件配置""" + return { + "corp_id": generate_random_string(), + "agent_id": generate_random_string(), + "secret": generate_random_string(), + } + + +@pytest.fixture() +def data_source_match_rules(default_data_source) -> List[Dict[str, Any]]: + """匹配数据源规则""" + return [ + { + "data_source_id": default_data_source.id, + # Note: 当前只允许匹配内建字段 + "field_compare_rules": [{"source_field": "user_id", "target_field": "username"}], + } + ] + + +@pytest.fixture() +def wecom_idp(bk_user, default_tenant, wecom_plugin_cfg, data_source_match_rules) -> Idp: + return Idp.objects.create( + name=generate_random_string(), + owner_tenant_id=default_tenant.id, + plugin=IdpPlugin.objects.get(id=BuiltinIdpPluginEnum.WECOM), + plugin_config=wecom_plugin_cfg, + data_source_match_rules=data_source_match_rules, + creator=bk_user.username, + updater=bk_user.username, + ) + + +class TestIdpPluginListApi: + def test_list(self, api_client): + resp = api_client.get(reverse("idp_plugin.list")) + # 至少有一个默认的本地账密认证源插件 + assert len(resp.data) >= 1 + assert BuiltinIdpPluginEnum.LOCAL in [i["id"] for i in resp.data] + + +class TestIdpCreateApi: + def test_create_with_wecom_idp(self, api_client, wecom_plugin_cfg, data_source_match_rules): + resp = api_client.post( + reverse("idp.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": BuiltinIdpPluginEnum.WECOM, + "plugin_config": wecom_plugin_cfg, + "data_source_match_rules": data_source_match_rules, + }, + ) + assert resp.status_code == status.HTTP_201_CREATED + + def test_create_with_not_exist_plugin(self, api_client): + resp = api_client.post( + reverse("idp.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": generate_random_string(), + "plugin_config": {}, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "认证源插件不存在" in resp.data["message"] + + def test_create_with_not_allowed_local_idp(self, api_client): + resp = api_client.post( + reverse("idp.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": BuiltinIdpPluginEnum.LOCAL, + "plugin_config": {}, + "data_source_match_rules": [], + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "不允许创建本地账密认证源" in resp.data["message"] + + def test_create_with_invalid_plugin_config(self, api_client, data_source_match_rules): + request_data = { + "name": generate_random_string(), + "plugin_id": BuiltinIdpPluginEnum.WECOM, + "data_source_match_rules": data_source_match_rules, + "plugin_config": {}, + } + + resp = api_client.post(reverse("idp.list_create"), data=request_data) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "认证源插件配置不合法" in resp.data["message"] + + request_data["plugin_config"] = {"corp_id": generate_random_string()} + resp = api_client.post(reverse("idp.list_create"), data=request_data) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "认证源插件配置不合法" in resp.data["message"] + + def test_create_with_invalid_data_source_match_rules(self, api_client, wecom_plugin_cfg, default_data_source): + request_data = { + "name": generate_random_string(), + "plugin_id": BuiltinIdpPluginEnum.WECOM, + "plugin_config": wecom_plugin_cfg, + "data_source_match_rules": [ + { + "data_source_id": 100000000000, + "field_compare_rules": [{"source_field": "user_id", "target_field": "username"}], + } + ], + } + + resp = api_client.post(reverse("idp.list_create"), data=request_data) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "数据源必须是当前租户下的" in resp.data["message"] + + request_data["data_source_match_rules"] = [ + { + "data_source_id": default_data_source.id, + "field_compare_rules": [{"source_field": "user_id", "target_field": "not_builtin_field"}], + } + ] + resp = api_client.post(reverse("idp.list_create"), data=request_data) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "当前仅支持匹配内置字段" in resp.data["message"] + + def test_create_with_empty_data_source_match_rules(self, api_client, wecom_plugin_cfg, default_data_source): + request_data = { + "name": generate_random_string(), + "plugin_id": BuiltinIdpPluginEnum.WECOM, + "plugin_config": wecom_plugin_cfg, + "data_source_match_rules": [], + } + resp = api_client.post(reverse("idp.list_create"), data=request_data) + assert resp.status_code == status.HTTP_201_CREATED + + +class TestIdpListApi: + def test_list(self, api_client, default_idp): + resp = api_client.get(reverse("idp.list_create")) + assert len(resp.data) != 0 + + resp = api_client.get(reverse("idp.list_create"), data={"keyword": default_idp.name}) + assert len(resp.data) == 1 + + idp = resp.data[0] + assert idp["id"] == default_idp.id + + +class TestIdpUpdateApi: + def test_update_with_wecom_idp(self, api_client, wecom_idp): + new_name = generate_random_string() + new_plugin_config = { + "corp_id": generate_random_string(), + "agent_id": generate_random_string(), + "secret": generate_random_string(), + } + resp = api_client.put( + reverse("idp.retrieve_update", kwargs={"id": wecom_idp.id}), + data={"name": new_name, "plugin_config": new_plugin_config, "data_source_match_rules": []}, + ) + assert resp.status_code == status.HTTP_204_NO_CONTENT + + idp = Idp.objects.get(id=wecom_idp.id) + assert idp.name == new_name + assert len(idp.data_source_match_rules) == 0 + assert idp.plugin_config == new_plugin_config + + def test_update_with_invalid_plugin_config(self, api_client, wecom_idp): + resp = api_client.put( + reverse("idp.retrieve_update", kwargs={"id": wecom_idp.id}), + data={ + "name": wecom_idp.name, + "plugin_config": {}, + "data_source_match_rules": wecom_idp.data_source_match_rules, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "认证源插件配置不合法" in resp.data["message"] + + resp = api_client.put( + reverse("idp.retrieve_update", kwargs={"id": wecom_idp.id}), + data={ + "name": wecom_idp.name, + "plugin_config": {"corp_id": generate_random_string()}, + "data_source_match_rules": wecom_idp.data_source_match_rules, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "认证源插件配置不合法" in resp.data["message"] + + def test_partial_update_with_name(self, api_client, wecom_idp): + new_name = generate_random_string() + resp = api_client.patch( + reverse("idp.retrieve_update", kwargs={"id": wecom_idp.id}), + data={"name": new_name, "data_source_match_rules": []}, + ) + assert resp.status_code == status.HTTP_204_NO_CONTENT + + idp = Idp.objects.get(id=wecom_idp.id) + assert idp.name == new_name + assert len(idp.data_source_match_rules) == len(wecom_idp.data_source_match_rules) + + def test_partial_update_with_duplicate_name(self, bk_user, api_client, wecom_idp): + new_name = generate_random_string() + Idp.objects.create( + name=new_name, + owner_tenant_id=wecom_idp.owner_tenant_id, + plugin=wecom_idp.plugin, + plugin_config=wecom_idp.plugin_config, + data_source_match_rules=wecom_idp.data_source_match_rules, + creator=bk_user.username, + updater=bk_user.username, + ) + resp = api_client.patch(reverse("idp.retrieve_update", kwargs={"id": wecom_idp.id}), data={"name": new_name}) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "同名认证源已存在" in resp.data["message"] + + +class TestIdpRetrieveApi: + def test_retrieve(self, api_client, wecom_idp): + resp = api_client.get(reverse("idp.retrieve_update", kwargs={"id": wecom_idp.id})) + assert resp.data["id"] == wecom_idp.id + assert resp.data["name"] == wecom_idp.name + assert resp.data["owner_tenant_id"] == wecom_idp.owner_tenant_id + assert resp.data["status"] == wecom_idp.status + assert resp.data["plugin"]["id"] == wecom_idp.plugin.id + assert resp.data["plugin"]["name"] == wecom_idp.plugin.name + assert resp.data["plugin_config"] == wecom_idp.plugin_config + assert resp.data["data_source_match_rules"] == wecom_idp.data_source_match_rules + assert resp.data["callback_uri"] == wecom_idp.callback_uri diff --git a/src/bk-user/tests/test_utils/__init__.py b/src/bk-user/tests/test_utils/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/test_utils/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" diff --git a/src/bk-user/tests/test_utils/auth.py b/src/bk-user/tests/test_utils/auth.py index ecbf8f1c5..ee764b491 100644 --- a/src/bk-user/tests/test_utils/auth.py +++ b/src/bk-user/tests/test_utils/auth.py @@ -14,6 +14,7 @@ from bkuser.apps.data_source.models import DataSource, DataSourceUser from bkuser.apps.tenant.models import Tenant, TenantManager, TenantUser from bkuser.auth.models import User + from tests.test_utils.helpers import generate_random_string diff --git a/src/bk-user/tests/test_utils/data_source.py b/src/bk-user/tests/test_utils/data_source.py index 5690c0263..314d7fdc8 100644 --- a/src/bk-user/tests/test_utils/data_source.py +++ b/src/bk-user/tests/test_utils/data_source.py @@ -19,6 +19,7 @@ DataSourceUser, DataSourceUserLeaderRelation, ) + from tests.test_utils.helpers import generate_random_string diff --git a/src/bk-user/tests/test_utils/natural_user.py b/src/bk-user/tests/test_utils/natural_user.py index 6bf04ae38..b2f874b2c 100644 --- a/src/bk-user/tests/test_utils/natural_user.py +++ b/src/bk-user/tests/test_utils/natural_user.py @@ -12,6 +12,7 @@ from bkuser.apps.data_source.models import DataSourceUser from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation, NaturalUser + from tests.test_utils.helpers import generate_random_string diff --git a/src/idp-plugins/idp_plugins/base.py b/src/idp-plugins/idp_plugins/base.py index 5c4e485f5..ac70c6d0f 100644 --- a/src/idp-plugins/idp_plugins/base.py +++ b/src/idp-plugins/idp_plugins/base.py @@ -163,3 +163,8 @@ def get_plugin_type(plugin_id: str) -> PluginTypeEnum: f"plugin class({plugin_cls.__name__}) must is a subclass of " f"{BaseCredentialIdpPlugin.__name__} or {BaseFederationIdpPlugin.__name__}" ) + + +def list_plugin_cls() -> List[Type[BaseCredentialIdpPlugin] | Type[BaseFederationIdpPlugin]]: + """获取插件类列表""" + return list(_plugin_cls_map.values()) diff --git a/src/idp-plugins/pyproject.toml b/src/idp-plugins/pyproject.toml index e5300a5e1..b0e053dc9 100644 --- a/src/idp-plugins/pyproject.toml +++ b/src/idp-plugins/pyproject.toml @@ -91,6 +91,7 @@ exclude = [ "venv", "*/migrations/*", ] +force-exclude = true [tool.ruff.mccabe] # Unlike Flake8, default to a complexity level of 10.