Skip to content

Commit

Permalink
feat: support mask idp plugin config sensitive info (#1413)
Browse files Browse the repository at this point in the history
  • Loading branch information
narasux authored Nov 22, 2023
1 parent 5a91d29 commit 69e08be
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 43 deletions.
8 changes: 6 additions & 2 deletions src/bk-user/bkuser/apis/login/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions src/bk-user/bkuser/apis/web/idp/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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)))
30 changes: 15 additions & 15 deletions src/bk-user/bkuser/apis/web/idp/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
11 changes: 6 additions & 5 deletions src/bk-user/bkuser/apps/idp/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# 对于启用登录,则需要添加进配置
Expand All @@ -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)
33 changes: 33 additions & 0 deletions src/bk-user/bkuser/apps/idp/migrations/0003_auto_20231122_1109.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
68 changes: 65 additions & 3 deletions src/bk-user/bkuser/apps/idp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""认证源"""

Expand All @@ -48,6 +67,8 @@ class Idp(AuditedModel):
# 允许关联社会化认证源的租户组织架构范围
allow_bind_scopes = models.JSONField("允许范围", default=list)

objects = IdpManager()

class Meta:
ordering = ["created_at"]
unique_together = [
Expand All @@ -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")]
2 changes: 1 addition & 1 deletion src/bk-user/bkuser/plugins/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class BasePluginConfig(BaseModel):
"""插件配置基类"""

# 注:敏感字段声明有以下规范
# 字段形式如: auth_config.password
# 字段形式如: auth_config.password
# 字段类型为 str 或 (str | None)
# 字段路径中不支持列表下标,只能是字典 key
sensitive_fields: ClassVar[List[str]] = []
Expand Down
17 changes: 13 additions & 4 deletions src/bk-user/tests/apis/web/idp/test_idp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/idp-plugins/idp_plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/idp-plugins/idp_plugins/local/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
""" "本地账密认证源插件配置"""

# 开启账密登录的数据源
Expand Down
Loading

0 comments on commit 69e08be

Please sign in to comment.