diff --git a/src/bk-user/bkuser/apis/web/data_source/serializers.py b/src/bk-user/bkuser/apis/web/data_source/serializers.py index 091a5286e..5fc488d08 100644 --- a/src/bk-user/bkuser/apis/web/data_source/serializers.py +++ b/src/bk-user/bkuser/apis/web/data_source/serializers.py @@ -11,6 +11,8 @@ import logging from typing import Any, Dict, List +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_serializer_method from pydantic import ValidationError as PDValidationError @@ -20,8 +22,10 @@ from bkuser.apps.data_source.constants import FieldMappingOperation from bkuser.apps.data_source.models import DataSource, DataSourcePlugin from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField +from bkuser.biz.data_source_plugin import DefaultPluginConfigProvider from bkuser.plugins.base import get_plugin_cfg_cls from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.models import PasswordRuleConfig from bkuser.utils.pydantic import stringify_pydantic_error logger = logging.getLogger(__name__) @@ -146,11 +150,18 @@ class DataSourceRetrieveOutputSLZ(serializers.Serializer): class DataSourceUpdateInputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="数据源名称", max_length=128) plugin_config = serializers.JSONField(help_text="数据源插件配置") field_mapping = serializers.ListField( help_text="用户字段映射", child=DataSourceFieldMappingSLZ(), allow_empty=True, required=False, default=list ) + def validate_name(self, name: str) -> str: + if DataSource.objects.filter(name=name).exists(): + raise ValidationError(_("同名数据源已存在")) + + return name + def validate_plugin_config(self, plugin_config: Dict[str, Any]) -> Dict[str, Any]: PluginConfigCls = get_plugin_cfg_cls(self.context["plugin_id"]) # noqa: N806 # 自定义插件,可能没有对应的配置类,不需要做格式检查 @@ -209,6 +220,32 @@ class DataSourceTestConnectionOutputSLZ(serializers.Serializer): department = RawDataSourceDepartmentSLZ(help_text="部门") +class DataSourceRandomPasswordInputSLZ(serializers.Serializer): + """生成随机密码""" + + password_rule_config = serializers.JSONField(help_text="密码规则配置", required=False) + + def validate(self, attrs): + passwd_rule_cfg = attrs.get("password_rule_config") + if passwd_rule_cfg: + try: + attrs["password_rule"] = PasswordRuleConfig(**passwd_rule_cfg).to_rule() + except PDValidationError as e: + raise ValidationError(_("密码规则配置不合法: {}").format(stringify_pydantic_error(e))) + else: + attrs["password_rule"] = ( + DefaultPluginConfigProvider().get(DataSourcePluginEnum.LOCAL).password_rule.to_rule() # type: ignore + ) + + return attrs + + +class DataSourceRandomPasswordOutputSLZ(serializers.Serializer): + """生成随机密码结果""" + + password = serializers.CharField(help_text="密码") + + class LocalDataSourceImportInputSLZ(serializers.Serializer): """本地数据源导入""" @@ -216,6 +253,15 @@ class LocalDataSourceImportInputSLZ(serializers.Serializer): overwrite = serializers.BooleanField(help_text="允许对同名用户覆盖更新", default=False) incremental = serializers.BooleanField(help_text="是否使用增量同步", default=False) + def validate_file(self, file: UploadedFile) -> UploadedFile: + if not file.name.endswith(".xlsx"): + raise ValidationError(_("待导入文件必须为 Excel 格式")) + + if file.size > settings.MAX_USER_DATA_FILE_SIZE * 1024 * 1024: + raise ValidationError(_("待导入文件大小不得超过 {} M").format(settings.MAX_USER_DATA_FILE_SIZE)) + + return file + class LocalDataSourceImportOutputSLZ(serializers.Serializer): """本地数据源导入结果""" diff --git a/src/bk-user/bkuser/apis/web/data_source/urls.py b/src/bk-user/bkuser/apis/web/data_source/urls.py index 01fa2e675..3d050b747 100644 --- a/src/bk-user/bkuser/apis/web/data_source/urls.py +++ b/src/bk-user/bkuser/apis/web/data_source/urls.py @@ -23,6 +23,8 @@ ), # 数据源创建/获取列表 path("", views.DataSourceListCreateApi.as_view(), name="data_source.list_create"), + # 数据源随机密码获取 + path("random-passwords/", views.DataSourceRandomPasswordApi.as_view(), name="data_source.random_passwords"), # 数据源更新/获取 path("/", views.DataSourceRetrieveUpdateApi.as_view(), name="data_source.retrieve_update"), # 数据源启/停 diff --git a/src/bk-user/bkuser/apis/web/data_source/views.py b/src/bk-user/bkuser/apis/web/data_source/views.py index c8da2ce66..fa5917588 100644 --- a/src/bk-user/bkuser/apis/web/data_source/views.py +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -24,6 +24,8 @@ DataSourceCreateOutputSLZ, DataSourcePluginDefaultConfigOutputSLZ, DataSourcePluginOutputSLZ, + DataSourceRandomPasswordInputSLZ, + DataSourceRandomPasswordOutputSLZ, DataSourceRetrieveOutputSLZ, DataSourceSearchInputSLZ, DataSourceSearchOutputSLZ, @@ -42,6 +44,7 @@ from bkuser.biz.data_source_plugin import DefaultPluginConfigProvider from bkuser.biz.exporters import DataSourceUserExporter from bkuser.common.error_codes import error_codes +from bkuser.common.passwd import PasswordGenerator from bkuser.common.response import convert_workbook_to_response from bkuser.common.views import ExcludePatchAPIViewMixin, ExcludePutAPIViewMixin from bkuser.plugins.base import get_plugin_cfg_schema_map @@ -179,6 +182,7 @@ def put(self, request, *args, **kwargs): data = slz.validated_data with transaction.atomic(): + data_source.name = data["name"] data_source.plugin_config = data["plugin_config"] data_source.field_mapping = data["field_mapping"] data_source.updater = request.user.username @@ -187,6 +191,22 @@ def put(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) +class DataSourceRandomPasswordApi(generics.CreateAPIView): + @swagger_auto_schema( + tags=["data_source"], + operation_description="生成数据源用户随机密码", + request_body=DataSourceRandomPasswordInputSLZ(), + responses={status.HTTP_200_OK: DataSourceRandomPasswordOutputSLZ()}, + ) + def post(self, request, *args, **kwargs): + slz = DataSourceRandomPasswordInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + passwd = PasswordGenerator(data["password_rule"]).generate() + return Response(DataSourceRandomPasswordOutputSLZ(instance={"password": passwd}).data) + + class DataSourceTestConnectionApi(CurrentUserTenantDataSourceMixin, generics.RetrieveAPIView): """数据源连通性测试""" diff --git a/src/bk-user/bkuser/apps/data_source/constants.py b/src/bk-user/bkuser/apps/data_source/constants.py index 2711268a8..cf9860b7c 100644 --- a/src/bk-user/bkuser/apps/data_source/constants.py +++ b/src/bk-user/bkuser/apps/data_source/constants.py @@ -8,12 +8,10 @@ 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. """ -import re - from blue_krill.data_types.enum import EnumField, StructuredEnum from django.utils.translation import gettext_lazy as _ -DATA_SOURCE_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{2,31}") +from bkuser.plugins.local.constants import USERNAME_REGEX as DATA_SOURCE_USERNAME_REGEX # noqa: F401 class DataSourceStatus(str, StructuredEnum): diff --git a/src/bk-user/bkuser/biz/data_source_plugin.py b/src/bk-user/bkuser/biz/data_source_plugin.py index 9ba04c858..256848414 100644 --- a/src/bk-user/bkuser/biz/data_source_plugin.py +++ b/src/bk-user/bkuser/biz/data_source_plugin.py @@ -52,7 +52,7 @@ def _get_default_local_plugin_config(self) -> BaseModel: not_continuous_letter=False, not_continuous_digit=False, not_repeated_symbol=False, - valid_time=30, + valid_time=90, max_retries=3, lock_time=60 * 60, ), diff --git a/src/bk-user/bkuser/biz/exporters.py b/src/bk-user/bkuser/biz/exporters.py index 294d7de7d..1f0d5146e 100644 --- a/src/bk-user/bkuser/biz/exporters.py +++ b/src/bk-user/bkuser/biz/exporters.py @@ -74,6 +74,7 @@ def export(self) -> Workbook: ) ) + self._set_all_cells_to_text_format() return self.workbook def _load_template(self): @@ -83,11 +84,8 @@ def _load_template(self): self.sheet.alignment = Alignment(wrapText=True) # 补充租户用户自定义字段 self._update_sheet_custom_field_columns() - - # 将单元格设置为纯文本模式,防止出现类型转换 - for columns in self.sheet.columns: - for cell in columns: - cell.number_format = FORMAT_TEXT + # 将所有单元格设置为文本格式 + self._set_all_cells_to_text_format() def _update_sheet_custom_field_columns(self): """在模版中补充自定义字段""" @@ -107,6 +105,12 @@ def _update_sheet_custom_field_columns(self): # 设置默认列宽 self.sheet.column_dimensions[self._gen_sheet_col_idx(col_idx)].width = self.default_column_width + def _set_all_cells_to_text_format(self): + # 将单元格设置为纯文本模式,防止出现类型转换 + for columns in self.sheet.columns: + for cell in columns: + cell.number_format = FORMAT_TEXT + @staticmethod def _gen_sheet_col_idx(idx: int) -> str: """ diff --git a/src/bk-user/bkuser/plugins/local/constants.py b/src/bk-user/bkuser/plugins/local/constants.py index 23b156114..e83d72e85 100644 --- a/src/bk-user/bkuser/plugins/local/constants.py +++ b/src/bk-user/bkuser/plugins/local/constants.py @@ -8,6 +8,7 @@ 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. """ +import re from blue_krill.data_types.enum import EnumField, StructuredEnum from django.utils.translation import gettext_lazy as _ @@ -36,6 +37,9 @@ # 保留的历史密码上限 MAX_RESERVED_PREVIOUS_PASSWORD_COUNT = 5 +# 数据源用户名规则 +USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{2,31}") + class PasswordGenerateMethod(str, StructuredEnum): """密码生成方式""" diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 9394cf934..a2c9e549f 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -506,7 +506,9 @@ # zxcvbn 会对密码进行总体强度评估(score [0, 4]),建议限制不能使用评分低于 3 的密码 MIN_ZXCVBN_PASSWORD_SCORE = env.int("MIN_ZXCVBN_PASSWORD_SCORE", 3) -# 数据导出配置 +# 数据导入/导出配置 +# 导入文件大小限制,单位为 MB +MAX_USER_DATA_FILE_SIZE = env.int("MAX_USER_DATA_FILE_SIZE", 10) # 导出文件名称前缀 EXPORT_EXCEL_FILENAME_PREFIX = "bk_user_export" # 成员,组织信息导出模板 diff --git a/src/bk-user/tests/apis/web/data_source/test_data_source.py b/src/bk-user/tests/apis/web/data_source/test_data_source.py index 9cb0e5d7f..f9f4edc25 100644 --- a/src/bk-user/tests/apis/web/data_source/test_data_source.py +++ b/src/bk-user/tests/apis/web/data_source/test_data_source.py @@ -217,21 +217,23 @@ def test_list_other_tenant_data_source(self, api_client, random_tenant, data_sou class TestDataSourceUpdateApi: def test_update_local_data_source(self, api_client, data_source, local_ds_plugin_config): + new_data_source_name = generate_random_string() local_ds_plugin_config["enable_account_password_login"] = False resp = api_client.put( reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), - data={"plugin_config": local_ds_plugin_config}, + data={"name": new_data_source_name, "plugin_config": local_ds_plugin_config}, ) assert resp.status_code == status.HTTP_204_NO_CONTENT resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + assert resp.data["name"] == new_data_source_name assert resp.data["plugin_config"]["enable_account_password_login"] is False def test_update_with_invalid_plugin_config(self, api_client, data_source, local_ds_plugin_config): local_ds_plugin_config.pop("enable_account_password_login") resp = api_client.put( reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), - data={"plugin_config": local_ds_plugin_config}, + data={"name": generate_random_string(), "plugin_config": local_ds_plugin_config}, ) assert resp.status_code == status.HTTP_400_BAD_REQUEST assert "插件配置不合法:enable_account_password_login: Field required" in resp.data["message"]