diff --git a/src/bk-user/bkuser/apis/login/serializers.py b/src/bk-user/bkuser/apis/login/serializers.py index 9e1c6743d..934584d86 100644 --- a/src/bk-user/bkuser/apis/login/serializers.py +++ b/src/bk-user/bkuser/apis/login/serializers.py @@ -8,11 +8,12 @@ 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 typing import Any, Dict, List from rest_framework import serializers from bkuser.apps.idp.constants import IdpStatus +from bkuser.apps.idp.models import Idp from bkuser.biz.validators import validate_data_source_user_username @@ -73,11 +74,14 @@ class IdpListOutputSLZ(serializers.Serializer): class IdpRetrieveOutputSLZ(IdpListOutputSLZ): owner_tenant_id = serializers.CharField(help_text="归属的租户 ID") - plugin_config = serializers.JSONField(help_text="认证源插件配置") + plugin_config = serializers.SerializerMethodField(help_text="认证源插件配置") class Meta: ref_name = "login.IdpRetrieveOutputSLZ" + def get_plugin_config(self, obj: Idp) -> Dict[str, Any]: + return obj.get_plugin_cfg().model_dump() + class TenantUserMatchInputSLZ(serializers.Serializer): idp_users = serializers.ListField( diff --git a/src/bk-user/bkuser/apis/web/idp/serializers.py b/src/bk-user/bkuser/apis/web/idp/serializers.py index 0337f4bbe..15d7e5974 100644 --- a/src/bk-user/bkuser/apis/web/idp/serializers.py +++ b/src/bk-user/bkuser/apis/web/idp/serializers.py @@ -21,7 +21,7 @@ from bkuser.apps.idp.constants import IdpStatus from bkuser.apps.idp.models import Idp, IdpPlugin from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField -from bkuser.idp_plugins.base import get_plugin_cfg_cls +from bkuser.idp_plugins.base import BasePluginConfig, get_plugin_cfg_cls from bkuser.idp_plugins.constants import BuiltinIdpPluginEnum from bkuser.utils.pydantic import stringify_pydantic_error @@ -152,7 +152,7 @@ def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: raise ValidationError(_("认证源插件 {} 不存在").format(plugin_id)) try: - attrs["plugin_config"] = cfg_cls(**attrs["plugin_config"]).model_dump() + attrs["plugin_config"] = cfg_cls(**attrs["plugin_config"]) except PDValidationError as e: raise ValidationError(_("认证源插件配置不合法:{}").format(stringify_pydantic_error(e))) @@ -192,10 +192,10 @@ class IdpUpdateInputSLZ(serializers.Serializer): 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]: + def validate_plugin_config(self, plugin_config: Dict[str, Any]) -> BasePluginConfig: cfg_cls = get_plugin_cfg_cls(self.context["plugin_id"]) try: - return cfg_cls(**plugin_config).model_dump() + return cfg_cls(**plugin_config) except PDValidationError as e: raise ValidationError(_("认证源插件配置不合法:{}").format(stringify_pydantic_error(e))) diff --git a/src/bk-user/bkuser/apis/web/idp/views.py b/src/bk-user/bkuser/apis/web/idp/views.py index e7a1a1d4c..612510b5b 100644 --- a/src/bk-user/bkuser/apis/web/idp/views.py +++ b/src/bk-user/bkuser/apis/web/idp/views.py @@ -125,16 +125,15 @@ def post(self, request, *args, **kwargs): 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, - ) + 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) @@ -197,10 +196,11 @@ def put(self, request, *args, **kwargs): 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"]) + with transaction.atomic(): + idp.name = data["name"] + idp.data_source_match_rules = data["data_source_match_rules"] + idp.updater = request.user.username + idp.save(update_fields=["name", "data_source_match_rules", "updater", "updated_at"]) + idp.set_plugin_cfg(data["plugin_config"]) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apps/idp/handlers.py b/src/bk-user/bkuser/apps/idp/handlers.py index 7d72673e6..2d8262ab1 100644 --- a/src/bk-user/bkuser/apps/idp/handlers.py +++ b/src/bk-user/bkuser/apps/idp/handlers.py @@ -53,7 +53,7 @@ def _update_local_idp_of_tenant(data_source: DataSource): enable_login = bool(data_source.status == DataSourceStatus.ENABLED and plugin_cfg.enable_account_password_login) # 根据数据源是否使用账密登录,修改认证源配置 - idp_plugin_cfg = LocalIdpPluginConfig(**idp.plugin_config) + idp_plugin_cfg: LocalIdpPluginConfig = idp.get_plugin_cfg() data_source_match_rules = idp.data_source_match_rule_objs # 对于启用登录,则需要添加进配置 @@ -68,7 +68,8 @@ def _update_local_idp_of_tenant(data_source: DataSource): data_source_match_rules = [i for i in data_source_match_rules if i.data_source_id != data_source.id] # 保存 - idp.plugin_config = idp_plugin_cfg.model_dump() - idp.data_source_match_rules = [i.model_dump() for i in data_source_match_rules] - idp.status = IdpStatus.ENABLED if idp_plugin_cfg.data_source_ids else IdpStatus.DISABLED - idp.save(update_fields=["plugin_config", "data_source_match_rules", "status", "updated_at"]) + with transaction.atomic(): + idp.data_source_match_rules = [i.model_dump() for i in data_source_match_rules] + idp.status = IdpStatus.ENABLED if idp_plugin_cfg.data_source_ids else IdpStatus.DISABLED + idp.save(update_fields=["data_source_match_rules", "status", "updated_at"]) + idp.set_plugin_cfg(idp_plugin_cfg) diff --git a/src/bk-user/bkuser/apps/idp/migrations/0003_auto_20231122_1109.py b/src/bk-user/bkuser/apps/idp/migrations/0003_auto_20231122_1109.py new file mode 100644 index 000000000..48ed9950b --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/migrations/0003_auto_20231122_1109.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.20 on 2023-11-22 03:09 + +import blue_krill.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('idp', '0002_init_builtin_idp_plugin'), + ] + + operations = [ + migrations.AlterModelOptions( + name='idp', + options={'ordering': ['created_at']}, + ), + migrations.CreateModel( + name='IdpSensitiveInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('key', models.CharField(max_length=255, verbose_name='配置字段路径')), + ('value', blue_krill.models.fields.EncryptField(max_length=255, verbose_name='敏感配置数据')), + ('idp', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.PROTECT, to='idp.idp')), + ], + options={ + 'unique_together': {('idp', 'key')}, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/idp/models.py b/src/bk-user/bkuser/apps/idp/models.py index 0ac673449..21630b708 100644 --- a/src/bk-user/bkuser/apps/idp/models.py +++ b/src/bk-user/bkuser/apps/idp/models.py @@ -11,12 +11,15 @@ from typing import List from urllib.parse import urljoin +from blue_krill.models.fields import EncryptField from django.conf import settings -from django.db import models +from django.db import models, transaction -from bkuser.common.models import AuditedModel -from bkuser.idp_plugins.base import get_plugin_type +from bkuser.common.constants import SENSITIVE_MASK +from bkuser.common.models import AuditedModel, TimestampedModel +from bkuser.idp_plugins.base import BasePluginConfig, get_plugin_cfg_cls, get_plugin_type from bkuser.idp_plugins.constants import BuiltinIdpPluginEnum, PluginTypeEnum +from bkuser.utils import dictx from bkuser.utils.uuid import generate_uuid from .constants import IdpStatus @@ -32,6 +35,22 @@ class IdpPlugin(models.Model): logo = models.TextField("Logo", null=True, blank=True, default="") +class IdpManager(models.Manager): + """认证源管理器类""" + + @transaction.atomic() + def create(self, *args, **kwargs): + if "plugin_config" not in kwargs: + return super().create(*args, **kwargs) + + plugin_cfg = kwargs.pop("plugin_config") + assert isinstance(plugin_cfg, BasePluginConfig) + + idp: Idp = super().create(*args, **kwargs) + idp.set_plugin_cfg(plugin_cfg) + return idp + + class Idp(AuditedModel): """认证源""" @@ -48,6 +67,8 @@ class Idp(AuditedModel): # 允许关联社会化认证源的租户组织架构范围 allow_bind_scopes = models.JSONField("允许范围", default=list) + objects = IdpManager() + class Meta: ordering = ["created_at"] unique_together = [ @@ -72,3 +93,44 @@ def callback_uri(self) -> str: return urljoin(settings.BK_LOGIN_URL, f"auth/idps/{self.id}/actions/callback/") return "" + + def get_plugin_cfg(self) -> BasePluginConfig: + """获取插件配置 + + 注意:使用该方法获取到的配置将会包含敏感信息,不适合通过 API 暴露出去,仅可用于内部逻辑流转 + API 要获取插件配置请使用 idp.plugin_config,其中的敏感信息将会被 ******* 取代 + """ + plugin_cfg = self.plugin_config + for info in IdpSensitiveInfo.objects.filter(idp=self): + dictx.set_items(plugin_cfg, info.key, info.value) + + PluginCfgCls = get_plugin_cfg_cls(self.plugin.id) # noqa: N806 + return PluginCfgCls(**plugin_cfg) + + def set_plugin_cfg(self, cfg: BasePluginConfig) -> None: + """设置插件配置,注意:该方法包含 DB 数据更新,需要在事务中执行""" + plugin_cfg = cfg.model_dump() + + # 由于单个插件的敏感字段不会很多,这里不采用批量创建/更新的方式 + for field in cfg.sensitive_fields: + sensitive_val = dictx.get_items(plugin_cfg, field) + # 若敏感字段无值,或者已经被替换为掩码,则不需要二次替换 + if not sensitive_val or sensitive_val == SENSITIVE_MASK: + continue + + IdpSensitiveInfo.objects.update_or_create(idp=self, key=field, defaults={"value": sensitive_val}) + dictx.set_items(plugin_cfg, field, SENSITIVE_MASK) + + self.plugin_config = plugin_cfg + self.save(update_fields=["plugin_config", "updated_at"]) + + +class IdpSensitiveInfo(TimestampedModel): + """认证源敏感配置信息""" + + idp = models.ForeignKey(Idp, on_delete=models.PROTECT, db_constraint=False) + key = models.CharField("配置字段路径", max_length=255) + value = EncryptField(verbose_name="敏感配置数据", max_length=255) + + class Meta: + unique_together = [("idp", "key")] diff --git a/src/bk-user/bkuser/plugins/models.py b/src/bk-user/bkuser/plugins/models.py index 76cd9946a..9ed90433d 100644 --- a/src/bk-user/bkuser/plugins/models.py +++ b/src/bk-user/bkuser/plugins/models.py @@ -25,7 +25,7 @@ class BasePluginConfig(BaseModel): """插件配置基类""" # 注:敏感字段声明有以下规范 - # 字段形式如: auth_config.password, + # 字段形式如: auth_config.password # 字段类型为 str 或 (str | None) # 字段路径中不支持列表下标,只能是字典 key sensitive_fields: ClassVar[List[str]] = [] diff --git a/src/bk-user/tests/apis/web/idp/test_idp.py b/src/bk-user/tests/apis/web/idp/test_idp.py index 2e525d890..4bfafc4e8 100644 --- a/src/bk-user/tests/apis/web/idp/test_idp.py +++ b/src/bk-user/tests/apis/web/idp/test_idp.py @@ -13,7 +13,9 @@ import pytest from bkuser.apps.data_source.models import DataSource from bkuser.apps.idp.models import Idp, IdpPlugin +from bkuser.common.constants import SENSITIVE_MASK from bkuser.idp_plugins.constants import BuiltinIdpPluginEnum +from bkuser.idp_plugins.wecom.plugin import WecomIdpPluginConfig from django.urls import reverse from rest_framework import status @@ -64,7 +66,7 @@ def wecom_idp(bk_user, default_tenant, wecom_plugin_cfg, data_source_match_rules name=generate_random_string(), owner_tenant_id=default_tenant.id, plugin=IdpPlugin.objects.get(id=BuiltinIdpPluginEnum.WECOM), - plugin_config=wecom_plugin_cfg, + plugin_config=WecomIdpPluginConfig(**wecom_plugin_cfg), data_source_match_rules=data_source_match_rules, creator=bk_user.username, updater=bk_user.username, @@ -194,14 +196,21 @@ def test_update_with_wecom_idp(self, api_client, wecom_idp): } 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": []}, + 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 + assert idp.plugin_config["corp_id"] == new_plugin_config["corp_id"] + assert idp.plugin_config["agent_id"] == new_plugin_config["agent_id"] + assert idp.plugin_config["secret"] == SENSITIVE_MASK + assert idp.get_plugin_cfg().model_dump() == new_plugin_config def test_update_with_invalid_plugin_config(self, api_client, wecom_idp): resp = api_client.put( @@ -244,7 +253,7 @@ def test_partial_update_with_duplicate_name(self, bk_user, api_client, wecom_idp name=new_name, owner_tenant_id=wecom_idp.owner_tenant_id, plugin=wecom_idp.plugin, - plugin_config=wecom_idp.plugin_config, + plugin_config=WecomIdpPluginConfig(**wecom_idp.plugin_config), data_source_match_rules=wecom_idp.data_source_match_rules, creator=bk_user.username, updater=bk_user.username, diff --git a/src/idp-plugins/idp_plugins/base.py b/src/idp-plugins/idp_plugins/base.py index ac70c6d0f..82ab08083 100644 --- a/src/idp-plugins/idp_plugins/base.py +++ b/src/idp-plugins/idp_plugins/base.py @@ -10,7 +10,7 @@ """ import logging from abc import ABC, abstractmethod -from typing import Any, Dict, List, Type +from typing import Any, ClassVar, Dict, List, Type from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from pydantic import BaseModel @@ -21,13 +21,23 @@ logger = logging.getLogger(__name__) +class BasePluginConfig(BaseModel): + """插件配置基类""" + + # 注:敏感字段声明有以下规范 + # 字段形式如: auth.secret + # 字段类型为 str 或 (str | None) + # 字段路径中不支持列表下标,只能是字典 key + sensitive_fields: ClassVar[List[str]] = [] + + class BaseIdpPlugin(ABC): """认证源插件基类""" # 插件唯一标识,比如oauth2、oidc、saml2、local,自定义插件需要以custom_为前缀 id: str # 插件本身的配置类,比如OAuth2.0可能需要提供ClientID/ClientSecret等等 - config_class: Type[BaseModel] + config_class: Type[BasePluginConfig] # 扩展请求的配置 dispatch_configs: List[DispatchConfigItem] @@ -141,7 +151,7 @@ def get_plugin_cls(plugin_id: str) -> Type[BaseCredentialIdpPlugin] | Type[BaseF return _plugin_cls_map[plugin_id] -def get_plugin_cfg_cls(plugin_id: str) -> Type[BaseModel]: +def get_plugin_cfg_cls(plugin_id: str) -> Type[BasePluginConfig]: """获取指定插件的配置类""" return get_plugin_cls(plugin_id).config_class diff --git a/src/idp-plugins/idp_plugins/local/plugin.py b/src/idp-plugins/idp_plugins/local/plugin.py index 9c879c60e..28c680eb5 100644 --- a/src/idp-plugins/idp_plugins/local/plugin.py +++ b/src/idp-plugins/idp_plugins/local/plugin.py @@ -12,16 +12,15 @@ from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ -from pydantic import BaseModel from .client import BkUserAPIClient -from ..base import BaseCredentialIdpPlugin +from ..base import BaseCredentialIdpPlugin, BasePluginConfig from ..exceptions import InvalidParamError, UnexpectedDataError from ..models import TestConnectionResult from ..utils import parse_request_body_json -class LocalIdpPluginConfig(BaseModel): +class LocalIdpPluginConfig(BasePluginConfig): """ "本地账密认证源插件配置""" # 开启账密登录的数据源 diff --git a/src/idp-plugins/idp_plugins/wecom/plugin.py b/src/idp-plugins/idp_plugins/wecom/plugin.py index 8d18a71a4..5213e5213 100644 --- a/src/idp-plugins/idp_plugins/wecom/plugin.py +++ b/src/idp-plugins/idp_plugins/wecom/plugin.py @@ -13,17 +13,18 @@ from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ -from pydantic import BaseModel from .client import WeComAPIClient from .settings import WECOM_OAUTH_URL -from ..base import BaseFederationIdpPlugin +from ..base import BaseFederationIdpPlugin, BasePluginConfig from ..exceptions import InvalidParamError from ..models import TestConnectionResult from ..utils import generate_random_str -class WecomIdpPluginConfig(BaseModel): +class WecomIdpPluginConfig(BasePluginConfig): + sensitive_fields = ["secret"] + corp_id: str agent_id: str secret: str