diff --git a/bkflow/apigw/serializers/credential.py b/bkflow/apigw/serializers/credential.py index a826b22a30..c64c156fdb 100644 --- a/bkflow/apigw/serializers/credential.py +++ b/bkflow/apigw/serializers/credential.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -20,28 +19,92 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -from bkflow.space.credential import BkAppCredential +from bkflow.space.credential import CredentialDispatcher +from bkflow.space.models import Credential, CredentialScopeLevel +from bkflow.space.serializers import CredentialScopeSerializer + + +class CredentialSerializer(serializers.ModelSerializer): + create_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") + update_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") + + def to_representation(self, instance): + data = super().to_representation(instance) + credential = CredentialDispatcher(credential_type=instance.type, data=instance.content) + if credential: + data["content"] = credential.display_value() + else: + data["content"] = {} + + return data + + class Meta: + model = Credential + fields = "__all__" class CreateCredentialSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("凭证名称"), max_length=32, required=True) - desc = serializers.CharField(help_text=_("凭证描述"), max_length=32, required=False) + desc = serializers.CharField(help_text=_("凭证描述"), max_length=128, required=False) type = serializers.CharField(help_text=_("凭证类型"), max_length=32, required=True) content = serializers.JSONField(help_text=_("凭证内容"), required=True) + scope_level = serializers.ChoiceField( + help_text=_("作用域级别"), + required=False, + default=CredentialScopeLevel.NONE.value, + choices=Credential.CREDENTIAL_SCOPE_LEVEL_CHOICES, + ) + scopes = serializers.ListField( + child=CredentialScopeSerializer(), help_text=_("凭证作用域列表"), required=False, default=list + ) + + def validate(self, attrs): + # 动态验证content根据type + credential_type = attrs.get("type") + content = attrs.get("content") - def validate_content(self, value): - content_ser = BkAppCredential.BkAppSerializer(data=value) - content_ser.is_valid(raise_exception=True) - return value + if attrs.get("scope_level") == CredentialScopeLevel.PART.value and not attrs.get("scopes"): + raise serializers.ValidationError(_("作用域不能为空")) + + try: + credential = CredentialDispatcher(credential_type, data=content) + credential.validate_data() + except Exception as e: + raise serializers.ValidationError({"content": str(e)}) + + return attrs class UpdateCredentialSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("凭证名称"), max_length=32, required=False) - desc = serializers.CharField(help_text=_("凭证描述"), max_length=32, required=False) + desc = serializers.CharField(help_text=_("凭证描述"), max_length=128, required=False) type = serializers.CharField(help_text=_("凭证类型"), max_length=32, required=False) content = serializers.JSONField(help_text=_("凭证内容"), required=False) + scope_level = serializers.ChoiceField( + help_text=_("作用域级别"), + required=False, + default=CredentialScopeLevel.NONE.value, + choices=Credential.CREDENTIAL_SCOPE_LEVEL_CHOICES, + ) + scopes = serializers.ListField(child=CredentialScopeSerializer(), help_text=_("凭证作用域列表"), required=False) + + def validate(self, attrs): + if attrs.get("scope_level") == CredentialScopeLevel.PART.value and not attrs.get("scopes"): + raise serializers.ValidationError(_("作用域不能为空")) + + # 如果提供了type和content,需要验证content + if "content" in attrs: + # 如果有type字段使用type,否则需要从实例获取 + credential_type = attrs.get("type") + if not credential_type and hasattr(self, "instance"): + credential_type = self.instance.type + + if credential_type: + content = attrs.get("content") + try: + credential = CredentialDispatcher(credential_type, data=content) + credential.validate_data() + except Exception as e: + raise serializers.ValidationError({"content": str(e)}) - def validate_content(self, value): - content_ser = BkAppCredential.BkAppSerializer(data=value) - content_ser.is_valid(raise_exception=True) - return value + return attrs diff --git a/bkflow/apigw/serializers/task.py b/bkflow/apigw/serializers/task.py index 9732e922f6..029d128d45 100644 --- a/bkflow/apigw/serializers/task.py +++ b/bkflow/apigw/serializers/task.py @@ -32,6 +32,7 @@ class CreateTaskSerializer(serializers.Serializer): creator = serializers.CharField(help_text=_("创建者"), max_length=USER_NAME_MAX_LENGTH, required=True) description = serializers.CharField(help_text=_("任务描述"), required=False) constants = serializers.JSONField(help_text=_("任务启动参数"), required=False, default={}) + credentials = serializers.JSONField(help_text=_("任务凭证"), required=False, default={}) class TaskMockDataSerializer(serializers.Serializer): @@ -48,6 +49,7 @@ class CreateMockTaskBaseSerializer(serializers.Serializer): mock_data = TaskMockDataSerializer(help_text=_("Mock 数据"), default=TaskMockDataSerializer()) description = serializers.CharField(help_text=_("任务描述"), required=False) constants = serializers.JSONField(help_text=_("任务启动参数"), default={}) + credentials = serializers.JSONField(help_text=_("任务凭证"), required=False, default={}) class CreateMockTaskWithPipelineTreeSerializer(CreateMockTaskBaseSerializer): @@ -86,6 +88,7 @@ class CreateTaskWithoutTemplateSerializer(serializers.Serializer): scope_value = serializers.CharField(help_text=_("任务范围值"), max_length=128, required=False) description = serializers.CharField(help_text=_("任务描述"), required=False) constants = serializers.JSONField(help_text=_("任务启动参数"), required=False, default={}) + credentials = serializers.JSONField(help_text=_("任务凭证"), required=False, default={}) pipeline_tree = serializers.JSONField(help_text=_("任务树"), required=True) notify_config = serializers.JSONField(help_text=_("通知配置"), required=False, default={}) diff --git a/bkflow/apigw/urls.py b/bkflow/apigw/urls.py index 8e4dff21e7..706df577dd 100644 --- a/bkflow/apigw/urls.py +++ b/bkflow/apigw/urls.py @@ -52,6 +52,7 @@ from bkflow.apigw.views.operate_task_node import operate_task_node from bkflow.apigw.views.renew_space_config import renew_space_config from bkflow.apigw.views.revoke_token import revoke_token + from bkflow.apigw.views.update_credential import update_credential from bkflow.apigw.views.update_template import update_template from bkflow.apigw.views.validate_pipeline_tree import validate_pipeline_tree @@ -73,6 +74,7 @@ url(r"^space/(?P\d+)/create_task_without_template/$", create_task_without_template), url(r"^space/(?P\d+)/validate_pipeline_tree/$", validate_pipeline_tree), url(r"^space/(?P\d+)/create_credential/$", create_credential), + url(r"^space/(?P\d+)/credential/(?P\d+)/$", update_credential), url(r"^space/(?P\d+)/get_task_list/$", get_task_list), url(r"^space/(?P\d+)/task/(?P\d+)/get_task_detail/$", get_task_detail), url(r"^space/(?P\d+)/task/(?P\d+)/get_task_states/$", get_task_states), diff --git a/bkflow/apigw/views/create_credential.py b/bkflow/apigw/views/create_credential.py index cb27a8c98c..913bfb62a5 100644 --- a/bkflow/apigw/views/create_credential.py +++ b/bkflow/apigw/views/create_credential.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -21,12 +20,13 @@ from apigw_manager.apigw.decorators import apigw_require from blueapps.account.decorators import login_exempt +from django.db import transaction from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from bkflow.apigw.decorators import check_jwt_and_space, return_json_response from bkflow.apigw.serializers.credential import CreateCredentialSerializer -from bkflow.space.models import Credential +from bkflow.space.models import Credential, CredentialScope, CredentialScopeLevel @login_exempt @@ -36,9 +36,39 @@ @check_jwt_and_space @return_json_response def create_credential(request, space_id): + """ + 创建凭证 + + :param request: HTTP 请求对象 + :param space_id: 空间ID + :return: 创建的凭证信息 + """ data = json.loads(request.body) ser = CreateCredentialSerializer(data=data) ser.is_valid(raise_exception=True) - # 序列化器已经检查过是否存在了 - credential = Credential.create_credential(**ser.data, space_id=space_id, creator=request.user.username) + + # 提取作用域数据 + credential_data = dict(ser.validated_data) + scopes = credential_data.pop("scopes", []) + scope_level = credential_data.pop("scope_level", None) + + # 创建凭证和作用域 + with transaction.atomic(): + # 序列化器已经检查过是否存在了 + credential = Credential.create_credential( + **credential_data, space_id=space_id, creator=request.user.username, scope_level=scope_level + ) + + # 创建凭证作用域 + if scope_level == CredentialScopeLevel.PART.value and scopes: + scope_objects = [ + CredentialScope( + credential_id=credential.id, + scope_type=scope.get("scope_type"), + scope_value=scope.get("scope_value"), + ) + for scope in scopes + ] + CredentialScope.objects.bulk_create(scope_objects) + return credential.display_json() diff --git a/bkflow/apigw/views/create_mock_task.py b/bkflow/apigw/views/create_mock_task.py index fb3b74fd58..982aca032a 100644 --- a/bkflow/apigw/views/create_mock_task.py +++ b/bkflow/apigw/views/create_mock_task.py @@ -29,6 +29,7 @@ from bkflow.constants import TaskTriggerMethod from bkflow.contrib.api.collections.task import TaskComponentClient from bkflow.exceptions import ValidationError +from bkflow.space.credential.resolver import resolve_credentials from bkflow.template.models import Template @@ -91,6 +92,13 @@ def create_mock_task(request, space_id): {"notify_config": template.notify_config or DEFAULT_NOTIFY_CONFIG} ) + # 处理凭证:解析并验证凭证,然后合并到pipeline_tree + credentials = create_task_data.get("credentials", {}) + if credentials: + resolved_credentials = resolve_credentials(credentials, space_id, template.scope_type, template.scope_value) + # 将解析后的凭证合并到pipeline_tree + create_task_data["pipeline_tree"].setdefault("credentials", {}).update(resolved_credentials) + client = TaskComponentClient(space_id=space_id) result = client.create_task(create_task_data) return result diff --git a/bkflow/apigw/views/create_task.py b/bkflow/apigw/views/create_task.py index a6ce2cc96a..96876649fb 100644 --- a/bkflow/apigw/views/create_task.py +++ b/bkflow/apigw/views/create_task.py @@ -30,6 +30,7 @@ from bkflow.constants import TaskTriggerMethod, WebhookEventType, WebhookScopeType from bkflow.contrib.api.collections.task import TaskComponentClient from bkflow.exceptions import ValidationError +from bkflow.space.credential.resolver import resolve_credentials from bkflow.template.models import Template from bkflow.utils.trace import CallFrom, trace_view @@ -68,6 +69,13 @@ def create_task(request, space_id): {"notify_config": template.notify_config or DEFAULT_NOTIFY_CONFIG} ) + # 处理凭证:解析并验证凭证,然后合并到pipeline_tree + credentials = create_task_data.get("credentials", {}) + if credentials: + resolved_credentials = resolve_credentials(credentials, space_id, template.scope_type, template.scope_value) + # 将解析后的凭证合并到pipeline_tree + create_task_data["pipeline_tree"].setdefault("credentials", {}).update(resolved_credentials) + client = TaskComponentClient(space_id=space_id) result = client.create_task(create_task_data) diff --git a/bkflow/apigw/views/create_task_without_template.py b/bkflow/apigw/views/create_task_without_template.py index c2c89bca69..88dfde1f1e 100644 --- a/bkflow/apigw/views/create_task_without_template.py +++ b/bkflow/apigw/views/create_task_without_template.py @@ -27,6 +27,7 @@ from bkflow.apigw.serializers.task import CreateTaskWithoutTemplateSerializer from bkflow.constants import TaskTriggerMethod from bkflow.contrib.api.collections.task import TaskComponentClient +from bkflow.space.credential.resolver import resolve_credentials @login_exempt @@ -50,6 +51,18 @@ def create_task_without_template(request, space_id): notify_config = create_task_data.pop("notify_config", {}) or DEFAULT_NOTIFY_CONFIG create_task_data.setdefault("extra_info", {}).update({"notify_config": notify_config}) + # 处理凭证:解析并验证凭证,然后合并到pipeline_tree + credentials = create_task_data.get("credentials", {}) + if credentials: + resolved_credentials = resolve_credentials( + credentials, + space_id, + create_task_data.get("scope_type"), + create_task_data.get("scope_value"), + ) + # 将解析后的凭证合并到pipeline_tree + create_task_data["pipeline_tree"].setdefault("credentials", {}).update(resolved_credentials) + client = TaskComponentClient(space_id=space_id) result = client.create_task(create_task_data) return result diff --git a/bkflow/apigw/views/update_credential.py b/bkflow/apigw/views/update_credential.py new file mode 100644 index 0000000000..da90a2fa7c --- /dev/null +++ b/bkflow/apigw/views/update_credential.py @@ -0,0 +1,89 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 + +from apigw_manager.apigw.decorators import apigw_require +from blueapps.account.decorators import login_exempt +from django.db import transaction +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from bkflow.apigw.decorators import check_jwt_and_space, return_json_response +from bkflow.apigw.serializers.credential import UpdateCredentialSerializer +from bkflow.exceptions import ValidationError +from bkflow.space.models import Credential, CredentialScope + + +@login_exempt +@csrf_exempt +@require_http_methods(["PUT", "PATCH"]) +@apigw_require +@check_jwt_and_space +@return_json_response +def update_credential(request, space_id, credential_id): + """ + 更新凭证 + + :param request: HTTP 请求对象 + :param space_id: 空间ID + :param credential_id: 凭证ID + :return: 更新后的凭证信息 + """ + data = json.loads(request.body) + ser = UpdateCredentialSerializer(data=data) + ser.is_valid(raise_exception=True) + + try: + credential = Credential.objects.get(id=credential_id, space_id=space_id, is_deleted=False) + except Credential.DoesNotExist: + raise ValidationError(_("凭证不存在: space_id={}, credential_id={}").format(space_id, credential_id)) + + with transaction.atomic(): + # 更新凭证基本信息 + credential_data = dict(ser.validated_data) + scopes_data = credential_data.pop("scopes", None) + + for attr, value in credential_data.items(): + if attr == "content": + # 使用update_credential方法来更新content,会做验证 + credential.update_credential(value) + else: + setattr(credential, attr, value) + + credential.updated_by = request.user.username + credential.save() + + # 更新凭证作用域 + if scopes_data is not None: + # 删除旧的作用域 + CredentialScope.objects.filter(credential_id=credential.id).delete() + # 创建新的作用域 + if scopes_data: + scope_objects = [ + CredentialScope( + credential_id=credential.id, + scope_type=scope.get("scope_type"), + scope_value=scope.get("scope_value"), + ) + for scope in scopes_data + ] + CredentialScope.objects.bulk_create(scope_objects) + + return credential.display_json() diff --git a/bkflow/exceptions.py b/bkflow/exceptions.py index 5ebb38754c..0ecf616aa7 100644 --- a/bkflow/exceptions.py +++ b/bkflow/exceptions.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -17,14 +16,17 @@ to the current version of the project delivered to anyone in the future. """ +from blueapps.core.exceptions.base import BlueException -class BKFLOWException(Exception): - CODE = None +class BKFLOWException(BlueException): + ERROR_CODE = None MESSAGE = None STATUS_CODE = 500 - def __init__(self, message=""): + def __init__(self, message="", code=None, errors=None): + self.code = code or "0000000" + self.data = errors or {} self.message = f"{self.MESSAGE}: {message}" if self.MESSAGE else f"{message}" def __str__(self): diff --git a/bkflow/pipeline_plugins/static/variables/credential.js b/bkflow/pipeline_plugins/static/variables/credential.js new file mode 100644 index 0000000000..f499e2c175 --- /dev/null +++ b/bkflow/pipeline_plugins/static/variables/credential.js @@ -0,0 +1,125 @@ + +(function () { + $.atoms.credential = [ + { + tag_code: "credential_meta", + type: "combine", + attrs: { + name: gettext("表格"), + hookable: true, + children: [ + { + tag_code: "credential_type", + type: "select", + attrs: { + name: gettext("凭证类型"), + hookable: true, + items: [ + { + text: gettext("蓝鲸应用凭证"), + value: "BK_APP" + }, + { + text: gettext("蓝鲸 Access Token 凭证"), + value: "BK_ACCESS_TOKEN" + }, + { + text: gettext("Basic Auth"), + value: "BASIC_AUTH" + }, + { + text: gettext("自定义"), + value: "CUSTOM" + } + ], + value: "BK_APP", + validation: [ + { + type: "required" + } + ] + } + }, + { + tag_code: "type", + type: "checkbox", + attrs: { + name: gettext("引用凭证"), + hookable: true, + items: [{value: "0"}], + value: ["0"], + validation: [ + { + type: "required" + } + ] + } + } + ] + } + + }, + { + tag_code: "credential", + meta_transform: function (variable) { + let metaConfig = variable.value; + let remote = false; + let remote_url = ""; + let items = []; + let placeholder = ''; + // if (metaConfig.datasource === "1") { + // remote_url = $.context.get('site_url') + 'api/plugin_query/variable_select_source_data_proxy/?url=' + metaConfig.items_text; + // remote = true; + // } + if (metaConfig.datasource === "0") { + try { + items = JSON.parse(metaConfig.items_text); + } catch (err) { + items = []; + placeholder = gettext('非法下拉框数据源,请检查您的配置'); + } + if (!(items instanceof Array)) { + items = []; + placeholder = gettext('非法下拉框数据源,请检查您的配置'); + } + } + + let multiple = false; + let default_val = metaConfig.default || ''; + + // if (metaConfig.type === "1") { + // multiple = true; + // default_val = []; + // if (metaConfig.default) { + // let vals = metaConfig.default.split(','); + // for (let i in vals) { + // default_val.push(vals[i].trim()); + // } + // } + // } + return { + tag_code: this.tag_code, + type: "select", + attrs: { + name: gettext("下拉框"), + hookable: true, + items: items, + multiple: multiple, + value: default_val, + remote: remote, + remote_url: remote_url, + placeholder: placeholder, + remote_data_init: function (data) { + return data; + }, + validation: [ + { + type: "required" + } + ] + } + } + } + } + ] +})(); diff --git a/bkflow/pipeline_plugins/variables/collections/credential.py b/bkflow/pipeline_plugins/variables/collections/credential.py new file mode 100644 index 0000000000..44d86306fa --- /dev/null +++ b/bkflow/pipeline_plugins/variables/collections/credential.py @@ -0,0 +1,44 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 typing import List + +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from pipeline.core.flow.io import StringItemSchema + +from bkflow.pipeline_plugins.variables.base import ( + CommonPlainVariable, + FieldExplain, + SelfExplainVariable, + Type, +) + + +class Credential(CommonPlainVariable, SelfExplainVariable): + code = "credential" + name = _("凭证") + type = "meta" + tag = "credential.credential" + meta_tag = "credential.credential_meta" + form = "{}variables/{}.js".format(settings.STATIC_URL, code) + schema = StringItemSchema(description=_("输入凭证")) + + @classmethod + def _self_explain(cls, **kwargs) -> List[FieldExplain]: + return [FieldExplain(key="${KEY}", type=Type.STRING, description="用户选择的凭证值")] diff --git a/bkflow/space/admin.py b/bkflow/space/admin.py index 5a8d84d628..c251a025dc 100644 --- a/bkflow/space/admin.py +++ b/bkflow/space/admin.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -44,3 +43,11 @@ class CredentialAdmin(admin.ModelAdmin): search_fields = ("space_id", "name", "type") list_filter = ("space_id",) ordering = ["-id"] + + +@admin.register(models.CredentialScope) +class CredentialScopeAdmin(admin.ModelAdmin): + list_display = ("id", "credential_id", "scope_type", "scope_value") + search_fields = ("credential_id", "scope_type", "scope_value") + list_filter = ("credential_id", "scope_type", "scope_value") + ordering = ["-id"] diff --git a/bkflow/space/credential/__init__.py b/bkflow/space/credential/__init__.py index 496f7d0932..d6fa97141f 100644 --- a/bkflow/space/credential/__init__.py +++ b/bkflow/space/credential/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -17,8 +16,16 @@ to the current version of the project delivered to anyone in the future. """ -from .bkapp import BkAppCredential # noqa +from .basic_auth import BasicAuthCredential # noqa +from .bk_access_token import BkAccessTokenCredential # noqa +from .bk_app import BkAppCredential # noqa +from .custom import CustomCredential # noqa from .dispatcher import CredentialDispatcher # noqa - -__ALL__ = ["BkAppCredential", "CredentialDispatcher"] +__ALL__ = [ + "BkAppCredential", + "BkAccessTokenCredential", + "BasicAuthCredential", + "CustomCredential", + "CredentialDispatcher", +] diff --git a/bkflow/space/credential/basic_auth.py b/bkflow/space/credential/basic_auth.py new file mode 100644 index 0000000000..0018a05592 --- /dev/null +++ b/bkflow/space/credential/basic_auth.py @@ -0,0 +1,70 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 rest_framework import serializers + +from bkflow.space.credential.base import BaseCredential + + +class BasicAuthCredential(BaseCredential): + """ + 用户名+密码凭证 + """ + + class BasicAuthSerializer(serializers.Serializer): + username = serializers.CharField(required=True) + password = serializers.CharField(required=True) + + def validate_password(self, value): + """ + 验证字段 password 的值,确保它不是全为 '*' + + :param value: password 值 + :return: 验证后的值 + """ + if all(char == "*" for char in value): + raise serializers.ValidationError("password 格式有误 不应全为 * 字符") + return value + + def value(self): + """ + 获取凭证真实值 + + :return: 凭证的实际内容 + """ + # todo 这里会涉及到加解密的操作 + return self.data + + def display_value(self): + """ + 获取凭证脱敏后的值 + + :return: 脱敏后的凭证内容(password 替换为星号) + """ + self.data["password"] = "*********" + return self.data + + def validate_data(self): + """ + 校验凭证数据格式 + + :return: 验证后的数据 + """ + ser = self.BasicAuthSerializer(data=self.data) + ser.is_valid(raise_exception=True) + return ser.validated_data diff --git a/bkflow/space/credential/bk_access_token.py b/bkflow/space/credential/bk_access_token.py new file mode 100644 index 0000000000..bf9563a28c --- /dev/null +++ b/bkflow/space/credential/bk_access_token.py @@ -0,0 +1,69 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 rest_framework import serializers + +from bkflow.space.credential.base import BaseCredential + + +class BkAccessTokenCredential(BaseCredential): + """ + 蓝鲸登录态凭证 + """ + + class BkAccessTokenSerializer(serializers.Serializer): + access_token = serializers.CharField(required=True) + + def validate_access_token(self, value): + """ + 验证字段 access_token 的值,确保它不是全为 '*' + + :param value: access_token 值 + :return: 验证后的值 + """ + if all(char == "*" for char in value): + raise serializers.ValidationError("access_token 格式有误 不应全为 * 字符") + return value + + def value(self): + """ + 获取凭证真实值 + + :return: 凭证的实际内容 + """ + # todo 这里会涉及到加解密的操作 + return self.data + + def display_value(self): + """ + 获取凭证脱敏后的值 + + :return: 脱敏后的凭证内容(access_token 替换为星号) + """ + self.data["access_token"] = "*********" + return self.data + + def validate_data(self): + """ + 校验凭证数据格式 + + :return: 验证后的数据 + """ + ser = self.BkAccessTokenSerializer(data=self.data) + ser.is_valid(raise_exception=True) + return ser.validated_data diff --git a/bkflow/space/credential/bkapp.py b/bkflow/space/credential/bk_app.py similarity index 76% rename from bkflow/space/credential/bkapp.py rename to bkflow/space/credential/bk_app.py index 83f44bc21d..940a4fc4ec 100644 --- a/bkflow/space/credential/bkapp.py +++ b/bkflow/space/credential/bk_app.py @@ -22,25 +22,49 @@ class BkAppCredential(BaseCredential): + """ + 蓝鲸应用凭证 + """ + class BkAppSerializer(serializers.Serializer): bk_app_code = serializers.CharField(required=True) bk_app_secret = serializers.CharField(required=True) def validate_bk_app_secret(self, value): - # 验证字段 bk_app_secret 的值,确保它不是全为 '*' + """ + 验证字段 bk_app_secret 的值,确保它不是全为 '*' + + :param value: bk_app_secret 值 + :return: 验证后的值 + """ if all(char == "*" for char in value): raise serializers.ValidationError("bk_app_secret 格式有误 不应全为 * 字符") return value def value(self): + """ + 获取凭证真实值 + + :return: 凭证的实际内容 + """ # todo 这里会涉及到加解密的操作 return self.data def display_value(self): + """ + 获取凭证脱敏后的值 + + :return: 脱敏后的凭证内容(bk_app_secret 替换为星号) + """ self.data["bk_app_secret"] = "*********" return self.data def validate_data(self): + """ + 校验凭证数据格式 + + :return: 验证后的数据 + """ ser = self.BkAppSerializer(data=self.data) ser.is_valid(raise_exception=True) return ser.validated_data diff --git a/bkflow/space/credential/custom.py b/bkflow/space/credential/custom.py new file mode 100644 index 0000000000..c2358e3f2d --- /dev/null +++ b/bkflow/space/credential/custom.py @@ -0,0 +1,70 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 rest_framework import serializers + +from bkflow.space.credential.base import BaseCredential + + +class CustomCredential(BaseCredential): + """ + 自定义凭证,支持任意key-value对 + """ + + def value(self): + """ + 获取凭证真实值 + + :return: 凭证的实际内容 + """ + # todo 这里会涉及到加解密的操作 + return self.data + + def display_value(self): + """ + 获取凭证脱敏后的值 + + :return: 脱敏后的凭证内容(所有value替换为星号) + """ + display_data = {} + for key in self.data.keys(): + display_data[key] = "*********" + return display_data + + def validate_data(self): + """ + 校验凭证数据格式 + + :return: 验证后的数据 + """ + if not isinstance(self.data, dict): + raise serializers.ValidationError("自定义凭证内容必须是字典类型") + + if not self.data: + raise serializers.ValidationError("自定义凭证内容不能为空") + + for key, value in self.data.items(): + if not isinstance(key, str): + raise serializers.ValidationError(f"凭证key必须是字符串类型: {key}") + if not isinstance(value, str): + raise serializers.ValidationError(f"凭证value必须是字符串类型: {key}={value}") + # 验证值不是全为 '*' + if all(char == "*" for char in value): + raise serializers.ValidationError(f"凭证值格式有误,不应全为 * 字符: {key}") + + return self.data diff --git a/bkflow/space/credential/dispatcher.py b/bkflow/space/credential/dispatcher.py index 470e88623a..a22195407e 100644 --- a/bkflow/space/credential/dispatcher.py +++ b/bkflow/space/credential/dispatcher.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -19,12 +18,20 @@ """ from django.utils.translation import ugettext_lazy as _ -from bkflow.space.credential.bkapp import BkAppCredential +from bkflow.space.credential.basic_auth import BasicAuthCredential +from bkflow.space.credential.bk_access_token import BkAccessTokenCredential +from bkflow.space.credential.bk_app import BkAppCredential +from bkflow.space.credential.custom import CustomCredential from bkflow.space.exceptions import CredentialTypeNotSupport class CredentialDispatcher: - CREDENTIAL_MAP = {"BK_APP": BkAppCredential} + CREDENTIAL_MAP = { + "BK_APP": BkAppCredential, + "BK_ACCESS_TOKEN": BkAccessTokenCredential, + "BASIC_AUTH": BasicAuthCredential, + "CUSTOM": CustomCredential, + } def __init__(self, credential_type, data): credential_cls = self.CREDENTIAL_MAP.get(credential_type) diff --git a/bkflow/space/credential/resolver.py b/bkflow/space/credential/resolver.py new file mode 100644 index 0000000000..0f254cf031 --- /dev/null +++ b/bkflow/space/credential/resolver.py @@ -0,0 +1,79 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 django.utils.translation import ugettext_lazy as _ + +from bkflow.space.credential.scope_validator import validate_credential_scope +from bkflow.space.exceptions import CredentialNotFoundError +from bkflow.space.models import Credential + + +def resolve_credentials(credentials_dict, space_id, scope_type=None, scope_value=None): + """ + 解析凭证字典,将凭证引用转换为实际的凭证值 + + :param credentials_dict: 凭证字典,格式为 {"${token1}": {"value": "credential_id_or_direct_value", ...}} + :param space_id: 空间ID + :param scope_type: 模板的作用域类型(可选) + :param scope_value: 模板的作用域值(可选) + + :return: 解析后的凭证字典,保留原有结构但填充实际值 + + :raises CredentialNotFoundError: 当引用的凭证不存在时 + :raises CredentialScopeValidationError: 当凭证不能在指定作用域使用时 + """ + if not credentials_dict: + return {} + + resolved_credentials = {} + + for key, cred_info in credentials_dict.items(): + # 创建凭证信息的副本 + resolved_info = dict(cred_info) + + # 获取凭证值 + value = cred_info.get("value", "") + + # 如果value是数字,尝试作为credential_id解析 + if isinstance(value, (int, str)) and str(value).isdigit(): + credential_id = int(value) + try: + credential = Credential.objects.get(id=credential_id, space_id=space_id, is_deleted=False) + + # 验证凭证作用域 + validate_credential_scope(credential, scope_type, scope_value) + + # 获取凭证的实际值 + resolved_info["value"] = credential.value + resolved_info["credential_id"] = credential_id + resolved_info["credential_name"] = credential.name + resolved_info["credential_type"] = credential.type + + except Credential.DoesNotExist: + raise CredentialNotFoundError( + _("凭证不存在: space_id={space_id}, credential_id={credential_id}").format( + space_id=space_id, credential_id=credential_id + ) + ) + else: + # 如果不是数字,直接使用提供的值 + resolved_info["value"] = value + + resolved_credentials[key] = resolved_info + + return resolved_credentials diff --git a/bkflow/space/credential/scope_validator.py b/bkflow/space/credential/scope_validator.py new file mode 100644 index 0000000000..8e3cad15ff --- /dev/null +++ b/bkflow/space/credential/scope_validator.py @@ -0,0 +1,92 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 django.utils.translation import ugettext_lazy as _ + +from bkflow.space.exceptions import CredentialScopeValidationError +from bkflow.space.models import CredentialScope, CredentialScopeLevel + + +def validate_credential_scope(credential, template_scope_type, template_scope_value): + """ + 验证凭证是否可以在指定的模板作用域中使用 + + :param credential: Credential 模型实例 + :param template_scope_type: 模板的作用域类型 + :param template_scope_value: 模板的作用域值 + :return: 验证通过返回 True + :raises CredentialScopeValidationError: 当凭证不能在指定作用域中使用时 + """ + if not credential.can_use_in_scope(template_scope_type, template_scope_value): + raise CredentialScopeValidationError( + _("凭证 {name}(ID:{id}) 不能在作用域 {scope_type}:{scope_value} 中使用").format( + name=credential.name, + id=credential.id, + scope_type=template_scope_type or "None", + scope_value=template_scope_value or "None", + ) + ) + return True + + +def filter_credentials_by_scope(credentials_queryset, scope_type, scope_value): + """ + 根据作用域过滤凭证列表,返回可以在指定作用域中使用的凭证 + + :param credentials_queryset: Credential 查询集 + :param scope_type: 作用域类型 + :param scope_value: 作用域值 + :return: 过滤后的凭证查询集 + """ + # 获取所有凭证ID + all_credential_ids = list(credentials_queryset.values_list("id", flat=True)) + + # 如果模板没有作用域(scope_type 和 scope_value 都为 None) + if not scope_type and not scope_value: + # 只返回 scope_level == ALL 的凭证(空间内开放) + return credentials_queryset.filter(id__in=all_credential_ids, scope_level=CredentialScopeLevel.ALL.value) + + # 模板有作用域时,需要过滤: + # 1. scope_level == ALL 的凭证(空间内开放,可以在任何地方使用) + # 2. scope_level == PART 且作用域匹配的凭证 + + # 获取 scope_level == ALL 的凭证ID + all_level_credential_ids = set( + credentials_queryset.filter(id__in=all_credential_ids, scope_level=CredentialScopeLevel.ALL.value).values_list( + "id", flat=True + ) + ) + + # 获取 scope_level == PART 的凭证ID + part_level_credential_ids = set( + credentials_queryset.filter(id__in=all_credential_ids, scope_level=CredentialScopeLevel.PART.value).values_list( + "id", flat=True + ) + ) + + # 查找 scope_level == PART 且作用域匹配的凭证ID + matching_credential_ids = set( + CredentialScope.objects.filter( + credential_id__in=part_level_credential_ids, scope_type=scope_type, scope_value=scope_value + ).values_list("credential_id", flat=True) + ) + + # 返回:scope_level == ALL 的凭证 + scope_level == PART 且作用域匹配的凭证 + available_credential_ids = all_level_credential_ids | matching_credential_ids + + return credentials_queryset.filter(id__in=available_credential_ids) diff --git a/bkflow/space/exceptions.py b/bkflow/space/exceptions.py index 859fd25c34..64813807ab 100644 --- a/bkflow/space/exceptions.py +++ b/bkflow/space/exceptions.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -46,3 +45,14 @@ class CredentialTypeNotSupport(BKFLOWException): class SpaceNotExists(BKFLOWException): CODE = None MESSAGE = _("空间不存在") + + +class CredentialScopeValidationError(BKFLOWException): + CODE = None + MESSAGE = _("凭证作用域验证失败") + + +class CredentialNotFoundError(BKFLOWException): + CODE = None + MESSAGE = _("凭证不存在") + STATUS_CODE = 404 diff --git a/bkflow/space/migrations/0008_auto_20251014_1511.py b/bkflow/space/migrations/0008_auto_20251014_1511.py new file mode 100644 index 0000000000..beefb6dd41 --- /dev/null +++ b/bkflow/space/migrations/0008_auto_20251014_1511.py @@ -0,0 +1,83 @@ +# Generated by Django 3.2.25 on 2025-10-14 07:11 + +from django.db import migrations, models + +import bkflow.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("space", "0007_alter_space_app_code"), + ] + + operations = [ + migrations.CreateModel( + name="CredentialScope", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("credential_id", models.IntegerField(db_index=True, verbose_name="凭证ID")), + ("scope_type", models.CharField(blank=True, max_length=128, null=True, verbose_name="作用域类型")), + ("scope_value", models.CharField(blank=True, max_length=128, null=True, verbose_name="作用域值")), + ], + options={ + "verbose_name": "凭证作用域", + "verbose_name_plural": "凭证作用域表", + }, + ), + migrations.AlterField( + model_name="credential", + name="content", + field=bkflow.utils.models.SecretSingleJsonField(blank=True, default=dict, null=True, verbose_name="凭证内容"), + ), + migrations.AlterField( + model_name="credential", + name="type", + field=models.CharField( + choices=[ + ("BK_APP", "蓝鲸应用凭证"), + ("BK_ACCESS_TOKEN", "蓝鲸登录态凭证"), + ("BASIC_AUTH", "用户名+密码"), + ("CUSTOM", "自定义"), + ], + max_length=32, + verbose_name="凭证类型", + ), + ), + migrations.AlterField( + model_name="spaceconfig", + name="name", + field=models.CharField( + choices=[ + ("token_expiration", "Token过期时间"), + ("token_auto_renewal", "是否开启Token自动续期"), + ("engine_space_config", "引擎模块配置"), + ("callback_hooks", "回调配置"), + ("uniform_api", "API 插件配置 (如更改配置,可能对已存在数据产生不兼容影响,请谨慎操作)"), + ("superusers", "空间管理员"), + ("canvas_mode", "画布模式"), + ("gateway_expression", "网关表达式"), + ("api_gateway_credential_name", "API_GATEWAY使用的凭证配置"), + ("space_plugin_config", "空间插件配置"), + ], + max_length=32, + verbose_name="配置项", + ), + ), + migrations.AlterField( + model_name="spaceconfig", + name="value_type", + field=models.CharField( + choices=[("JSON", "JSON"), ("TEXT", "文本"), ("REF", "引用")], + default="TEXT", + max_length=32, + verbose_name="配置类型", + ), + ), + migrations.AddIndex( + model_name="credentialscope", + index=models.Index( + fields=["credential_id", "scope_type", "scope_value"], name="space_crede_credent_6fe099_idx" + ), + ), + ] diff --git a/bkflow/space/migrations/0009_encrypt_credential_content.py b/bkflow/space/migrations/0009_encrypt_credential_content.py new file mode 100644 index 0000000000..6a2e7fb735 --- /dev/null +++ b/bkflow/space/migrations/0009_encrypt_credential_content.py @@ -0,0 +1,200 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 + +from django.conf import settings +from django.db import connection, migrations + +from bkflow.utils.crypt import BaseCrypt + + +def encrypt_existing_credentials(apps, schema_editor): + """ + 加密已存在的未加密凭证数据 + + :param apps: Django apps registry + :param schema_editor: 数据库 schema 编辑器 + """ + Credential = apps.get_model("space", "Credential") + table_name = Credential._meta.db_table + crypt = BaseCrypt(instance_key=settings.PRIVATE_SECRET) + + # 统计信息 + total_count = 0 + encrypted_count = 0 + skipped_count = 0 + error_count = 0 + + print("\n开始检查并加密未加密的凭证数据...") + + def is_value_encrypted(raw_value: str) -> bool: + """ + 通过解密并重新加密后与原始密文比对,判断是否为本系统的加密值。 + 说明:BaseCrypt 的加解密在相同 KEY/IV 下是确定性的,因此 encrypt(decrypt(x)) == x 成立。 + """ + if not isinstance(raw_value, str) or not raw_value: + return False + try: + plain = crypt.decrypt(raw_value) + re_encrypted = crypt.encrypt(plain) + return re_encrypted == raw_value + except Exception: + return False + + with connection.cursor() as cursor: + cursor.execute(f"SELECT id, name, type, content FROM {connection.ops.quote_name(table_name)}") + rows = cursor.fetchall() + + for row in rows: + total_count += 1 + cred_id, cred_name, cred_type, raw_content = row + + # 解析原始 content(以数据库中的原样字符串/JSON存储为准) + try: + content_obj = ( + raw_content if isinstance(raw_content, dict) else json.loads(raw_content) if raw_content else None + ) + except Exception: + content_obj = None + + if not content_obj or not isinstance(content_obj, dict): + skipped_count += 1 + continue + + try: + updated = False + encrypted_content = {} + for key, value in content_obj.items(): + if value is not None and isinstance(value, str): + # 已是密文则保持原样;否则进行加密 + if is_value_encrypted(value): + encrypted_content[key] = value + else: + encrypted_content[key] = crypt.encrypt(value) + updated = True + else: + encrypted_content[key] = value + + if not updated: + skipped_count += 1 + print(f" 跳过已加密的凭证: ID={cred_id}, Name={cred_name}") + continue + + # 直接以原生 SQL 更新,避免字段 from_db_value/to_python 的再次处理 + with connection.cursor() as cursor: + cursor.execute( + f"UPDATE {connection.ops.quote_name(table_name)} SET content=%s WHERE id=%s", + [json.dumps(encrypted_content), cred_id], + ) + + encrypted_count += 1 + print(f" ✓ 成功加密凭证: ID={cred_id}, Name={cred_name}, Type={cred_type}") + + except Exception as e: + error_count += 1 + print(f" ✗ 加密失败: ID={cred_id}, Name={cred_name}, Error={str(e)}") + + # 打印统计信息 + print("\n" + "=" * 70) + print("凭证数据加密完成!") + print(f" 总计: {total_count} 条") + print(f" 已加密: {encrypted_count} 条") + print(f" 已跳过: {skipped_count} 条(已加密或空数据)") + print(f" 失败: {error_count} 条") + print("=" * 70 + "\n") + + +def reverse_encrypt(apps, schema_editor): + """ + 回滚操作:解密已加密的凭证数据 + + :param apps: Django apps registry + :param schema_editor: 数据库 schema 编辑器 + """ + Credential = apps.get_model("space", "Credential") + table_name = Credential._meta.db_table + crypt = BaseCrypt(instance_key=settings.PRIVATE_SECRET) + + print("\n开始回滚:解密凭证数据...") + + decrypted_count = 0 + error_count = 0 + + def is_value_encrypted(raw_value: str) -> bool: + if not isinstance(raw_value, str) or not raw_value: + return False + try: + plain = crypt.decrypt(raw_value) + # 与加密迁移中相同的判定策略 + return crypt.encrypt(plain) == raw_value + except Exception: + return False + + with connection.cursor() as cursor: + cursor.execute(f"SELECT id, name, content FROM {connection.ops.quote_name(table_name)}") + rows = cursor.fetchall() + + for row in rows: + cred_id, cred_name, raw_content = row + try: + content_obj = ( + raw_content if isinstance(raw_content, dict) else json.loads(raw_content) if raw_content else None + ) + except Exception: + content_obj = None + if not content_obj or not isinstance(content_obj, dict): + continue + + try: + updated = False + decrypted_content = {} + for key, value in content_obj.items(): + if value is not None and isinstance(value, str) and is_value_encrypted(value): + decrypted_content[key] = crypt.decrypt(value) + updated = True + else: + decrypted_content[key] = value + + if not updated: + continue + + with connection.cursor() as cursor: + cursor.execute( + f"UPDATE {connection.ops.quote_name(table_name)} SET content=%s WHERE id=%s", + [json.dumps(decrypted_content), cred_id], + ) + decrypted_count += 1 + print(f" ✓ 成功解密凭证: ID={cred_id}, Name={cred_name}") + + except Exception as e: + error_count += 1 + print(f" ✗ 解密失败: ID={cred_id}, Name={cred_name}, Error={str(e)}") + + print(f"\n回滚完成!解密 {decrypted_count} 条,失败 {error_count} 条\n") + + +class Migration(migrations.Migration): + + dependencies = [ + ("space", "0008_auto_20251014_1511"), + ] + + operations = [ + migrations.RunPython(encrypt_existing_credentials, reverse_encrypt), + ] diff --git a/bkflow/space/migrations/0010_credential_scope_level.py b/bkflow/space/migrations/0010_credential_scope_level.py new file mode 100644 index 0000000000..375465d160 --- /dev/null +++ b/bkflow/space/migrations/0010_credential_scope_level.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2025-11-07 09:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("space", "0009_encrypt_credential_content"), + ] + + operations = [ + migrations.AddField( + model_name="credential", + name="scope_level", + field=models.CharField( + choices=[("all", "空间内开放"), ("part", "设置作用域"), ("none", "不开放")], + default="none", + max_length=32, + verbose_name="作用域级别", + ), + ), + ] diff --git a/bkflow/space/migrations/0011_set_credential_type.py b/bkflow/space/migrations/0011_set_credential_type.py new file mode 100644 index 0000000000..6f6ea61f80 --- /dev/null +++ b/bkflow/space/migrations/0011_set_credential_type.py @@ -0,0 +1,201 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 + +from django.conf import settings +from django.db import connection, migrations + +from bkflow.utils.crypt import BaseCrypt + + +def detect_credential_type(content_obj): + """ + 根据凭证内容判断凭证类型 + + :param content_obj: 解密后的凭证内容字典 + :return: 凭证类型字符串 + """ + if not content_obj or not isinstance(content_obj, dict): + return "CUSTOM" + + keys = set(content_obj.keys()) + + # BK_APP: 包含 bk_app_code 和 bk_app_secret,且没有其他字段(或只有这两个) + if keys == {"bk_app_code", "bk_app_secret"}: + return "BK_APP" + + # BK_ACCESS_TOKEN: 包含 access_token,且没有其他字段(或只有这一个) + if keys == {"access_token"}: + return "BK_ACCESS_TOKEN" + + # BASIC_AUTH: 包含 username 和 password,且没有其他字段(或只有这两个) + if keys == {"username", "password"}: + return "BASIC_AUTH" + + # 其他情况或无法确定,使用自定义 + return "CUSTOM" + + +def set_credential_type_from_content(apps, schema_editor): + """ + 根据凭证内容设置凭证类型 + + :param apps: Django apps registry + :param schema_editor: 数据库 schema 编辑器 + """ + Credential = apps.get_model("space", "Credential") + table_name = Credential._meta.db_table + crypt = BaseCrypt(instance_key=settings.PRIVATE_SECRET) + + # 统计信息 + total_count = 0 + updated_count = 0 + skipped_count = 0 + error_count = 0 + + print("\n开始根据凭证内容设置凭证类型...") + + def is_value_encrypted(raw_value: str) -> bool: + """ + 判断值是否已加密 + """ + if not isinstance(raw_value, str) or not raw_value: + return False + try: + plain = crypt.decrypt(raw_value) + re_encrypted = crypt.encrypt(plain) + return re_encrypted == raw_value + except Exception: + return False + + def decrypt_content(raw_content): + """ + 解密凭证内容 + + :param raw_content: 原始内容(可能是加密的 JSON 字符串或字典) + :return: 解密后的内容字典 + """ + # 解析 JSON + try: + content_obj = ( + raw_content if isinstance(raw_content, dict) else json.loads(raw_content) if raw_content else None + ) + except Exception: + content_obj = None + + if not content_obj or not isinstance(content_obj, dict): + return None + + # 解密每个值 + decrypted_content = {} + for key, value in content_obj.items(): + if value is not None and isinstance(value, str): + # 尝试解密 + if is_value_encrypted(value): + try: + decrypted_content[key] = crypt.decrypt(value) + except Exception: + # 解密失败,使用原值(可能是未加密的旧数据) + decrypted_content[key] = value + else: + # 未加密,直接使用 + decrypted_content[key] = value + else: + decrypted_content[key] = value + + return decrypted_content + + with connection.cursor() as cursor: + cursor.execute(f"SELECT id, name, type, content FROM {connection.ops.quote_name(table_name)}") + rows = cursor.fetchall() + + for row in rows: + total_count += 1 + cred_id, cred_name, cred_type, raw_content = row + + # 如果已经有类型且不是空字符串,跳过 + if cred_type and cred_type.strip(): + skipped_count += 1 + print(f" 跳过已有类型的凭证: ID={cred_id}, Name={cred_name}, Type={cred_type}") + continue + + try: + # 解密内容 + decrypted_content = decrypt_content(raw_content) + + if not decrypted_content: + # 无法解析内容,设置为自定义 + detected_type = "CUSTOM" + else: + # 根据内容判断类型 + detected_type = detect_credential_type(decrypted_content) + + # 更新类型 + with connection.cursor() as cursor: + cursor.execute( + f"UPDATE {connection.ops.quote_name(table_name)} SET type=%s WHERE id=%s", + [detected_type, cred_id], + ) + + updated_count += 1 + print(f" ✓ 成功设置凭证类型: ID={cred_id}, Name={cred_name}, Type={detected_type}") + + except Exception as e: + error_count += 1 + print(f" ✗ 设置凭证类型失败: ID={cred_id}, Name={cred_name}, Error={str(e)}") + + # 打印统计信息 + print("\n" + "=" * 70) + print("凭证类型设置完成!") + print(f" 总计: {total_count} 条") + print(f" 已更新: {updated_count} 条") + print(f" 已跳过: {skipped_count} 条(已有类型或空数据)") + print(f" 失败: {error_count} 条") + print("=" * 70 + "\n") + + +def reverse_set_credential_type(apps, schema_editor): + """ + 回滚操作:将凭证类型设置为 CUSTOM(因为无法确定原始类型) + + :param apps: Django apps registry + :param schema_editor: 数据库 schema 编辑器 + """ + Credential = apps.get_model("space", "Credential") + table_name = Credential._meta.db_table + + print("\n开始回滚:将凭证类型设置为 CUSTOM...") + print("注意:回滚操作会将所有凭证类型设置为 CUSTOM,因为无法确定原始类型") + + with connection.cursor() as cursor: + cursor.execute(f"UPDATE {connection.ops.quote_name(table_name)} SET type='CUSTOM'") + affected_rows = cursor.rowcount + + print(f"\n回滚完成!已将 {affected_rows} 条凭证的类型设置为 CUSTOM\n") + + +class Migration(migrations.Migration): + + dependencies = [ + ("space", "0010_credential_scope_level"), + ] + + operations = [ + migrations.RunPython(set_credential_type_from_content, reverse_set_credential_type), + ] diff --git a/bkflow/space/models.py b/bkflow/space/models.py index b35b626f09..9ccfc393e5 100644 --- a/bkflow/space/models.py +++ b/bkflow/space/models.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -33,7 +32,7 @@ ) from bkflow.space.credential import CredentialDispatcher from bkflow.space.exceptions import SpaceNotExists -from bkflow.utils.models import CommonModel +from bkflow.utils.models import CommonModel, SecretSingleJsonField class SpaceCreateType(Enum): @@ -253,16 +252,44 @@ def get_config(cls, space_id, config_name, *args, **kwargs): class CredentialType(Enum): # 蓝鲸应用凭证 BK_APP = "BK_APP" + # 蓝鲸登录态凭证 + BK_ACCESS_TOKEN = "BK_ACCESS_TOKEN" + # 用户名+密码 + BASIC_AUTH = "BASIC_AUTH" + # 自定义凭证 + CUSTOM = "CUSTOM" + + +class CredentialScopeLevel(Enum): + """凭证作用域级别""" + + ALL = "all" + PART = "part" + NONE = "none" class Credential(CommonModel): - CREDENTIAL_CHOICES = [(CredentialType.BK_APP.value, _("蓝鲸应用凭证"))] + CREDENTIAL_CHOICES = [ + (CredentialType.BK_APP.value, _("蓝鲸应用凭证")), + (CredentialType.BK_ACCESS_TOKEN.value, _("蓝鲸登录态凭证")), + (CredentialType.BASIC_AUTH.value, _("用户名+密码")), + (CredentialType.CUSTOM.value, _("自定义")), + ] + + CREDENTIAL_SCOPE_LEVEL_CHOICES = [ + (CredentialScopeLevel.ALL.value, _("空间内开放")), + (CredentialScopeLevel.PART.value, _("设置作用域")), + (CredentialScopeLevel.NONE.value, _("不开放")), + ] space_id = models.IntegerField(_("空间ID")) name = models.CharField(_("凭证名"), max_length=32) desc = models.CharField(_("凭证描述"), max_length=128, null=True, blank=True) type = models.CharField(_("凭证类型"), max_length=32, choices=CREDENTIAL_CHOICES) - content = models.JSONField(_("凭证内容"), null=True, blank=True, default=dict) + scope_level = models.CharField( + _("作用域级别"), max_length=32, choices=CREDENTIAL_SCOPE_LEVEL_CHOICES, default=CredentialScopeLevel.NONE.value + ) + content = SecretSingleJsonField(_("凭证内容"), null=True, blank=True, default=dict) def display_json(self): credential = CredentialDispatcher(self.type, data=self.content) @@ -281,14 +308,26 @@ def value(self): return credential.value() @classmethod - def create_credential(cls, space_id, name, type, content, creator, desc=None): + def create_credential(cls, space_id, name, type, content, creator, desc=None, scope_level=None): """ 创建一个凭证 + + :param space_id: 空间ID + :param name: 凭证名称 + :param type: 凭证类型 + :param content: 凭证内容 + :param creator: 创建者 + :param desc: 凭证描述(可选) + :param scope_level: 作用域级别(可选,默认为 NONE) + + :return: 创建的凭证实例 """ if not Space.exists(space_id): raise SpaceNotExists("space_id: {}".format(space_id)) credential = CredentialDispatcher(type, data=content) validate_data = credential.validate_data() + if scope_level is None: + scope_level = CredentialScopeLevel.NONE.value credential = cls( space_id=space_id, name=name, @@ -297,17 +336,95 @@ def create_credential(cls, space_id, name, type, content, creator, desc=None): content=validate_data, creator=creator, updated_by=creator, + scope_level=scope_level, ) credential.save() return credential def update_credential(self, content): + """ + 更新凭证内容 + + :param content: 新的凭证内容 + """ credential = CredentialDispatcher(self.type, data=content) validate_data = credential.validate_data() - self.data = validate_data + self.content = validate_data self.save() + def get_scopes(self): + """ + 获取凭证的作用域列表 + + :return: 凭证作用域查询集 + """ + return CredentialScope.objects.filter(credential_id=self.id) + + def has_scope(self): + """ + 检查凭证是否设置了作用域 + + :return: 如果设置了作用域返回 True,否则返回 False + """ + return self.get_scopes().exists() + + def can_use_in_scope(self, template_scope_type, template_scope_value): + """ + 检查凭证是否可以在指定作用域中使用 + + :param self: 凭证实例 + :param template_scope_type: 作用域类型 + :param template_scope_value: 作用域值 + :return: 如果可以使用返回 True,否则返回 False + """ + # scope_level == NONE 的凭证不能使用 + if self.scope_level == CredentialScopeLevel.NONE.value: + return False + + # scope_level == ALL 的凭证可以在任何地方使用 + if self.scope_level == CredentialScopeLevel.ALL.value: + return True + + # scope_level == PART 的凭证需要检查作用域匹配 + if self.scope_level == CredentialScopeLevel.PART.value: + # 如果模板没有作用域,PART 类型的凭证不能使用 + if not template_scope_type and not template_scope_value: + return False + + # 检查是否有匹配的作用域 + return ( + self.get_scopes() + .filter( + scope_type=template_scope_type, + scope_value=template_scope_value, + ) + .exists() + ) + + # 默认不允许使用(向后兼容旧逻辑) + return False + class Meta: verbose_name = _("空间凭证") verbose_name_plural = _("空间凭证表") unique_together = ("space_id", "name") + + +class CredentialScope(models.Model): + """ + 凭证作用域 + 用于控制凭证的使用范围 + 未关联任何作用域的凭证不受作用域限制,可以在任何地方使用 + """ + + id = models.AutoField(primary_key=True) + credential_id = models.IntegerField(_("凭证ID"), db_index=True) + scope_type = models.CharField(_("作用域类型"), max_length=128, null=True, blank=True) + scope_value = models.CharField(_("作用域值"), max_length=128, null=True, blank=True) + + class Meta: + verbose_name = _("凭证作用域") + verbose_name_plural = _("凭证作用域表") + indexes = [ + models.Index(fields=["credential_id", "scope_type", "scope_value"]), + ] diff --git a/bkflow/space/serializers.py b/bkflow/space/serializers.py index 1f207bae9b..aaf22b985c 100644 --- a/bkflow/space/serializers.py +++ b/bkflow/space/serializers.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -24,8 +23,7 @@ from bkflow.exceptions import ValidationError from bkflow.space.configs import SpaceConfigHandler, SpaceConfigValueType -from bkflow.space.credential import CredentialDispatcher -from bkflow.space.models import Credential, Space, SpaceConfig +from bkflow.space.models import CredentialScope, Space, SpaceConfig logger = logging.getLogger(__name__) @@ -60,6 +58,14 @@ class SpaceConfigBaseQuerySerializer(serializers.Serializer): space_id = serializers.IntegerField(help_text=_("空间ID")) +class CredentialScopeSerializer(serializers.ModelSerializer): + """凭证作用域序列化器""" + + class Meta: + model = CredentialScope + fields = ["scope_type", "scope_value"] + + class CredentialBaseQuerySerializer(serializers.Serializer): space_id = serializers.IntegerField(help_text=_("空间ID")) @@ -74,23 +80,3 @@ def validate_configs(self, configs): except ValidationError as e: logger.exception(f"[validate_configs] error: {e}") raise serializers.ValidationError(e.message) - - -class CredentialSerializer(serializers.ModelSerializer): - create_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") - update_at = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") - - def to_representation(self, instance): - data = super(CredentialSerializer, self).to_representation(instance) - credential = CredentialDispatcher(credential_type=instance.type, data=instance.content) - if credential: - data["content"] = credential.display_value() - data["data"] = credential.display_value() - else: - data["data"] = {} - - return data - - class Meta: - model = Credential - fields = "__all__" diff --git a/bkflow/space/views.py b/bkflow/space/views.py index f6262f3fe8..257d72500a 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -21,7 +21,7 @@ import django_filters from blueapps.account.decorators import login_exempt from django.conf import settings -from django.db import DatabaseError +from django.db import DatabaseError, transaction from django.db.models import Q from django.utils.decorators import method_decorator from django_filters.rest_framework import DjangoFilterBackend, FilterSet @@ -36,6 +36,7 @@ from bkflow.apigw.serializers.credential import ( CreateCredentialSerializer, + CredentialSerializer, UpdateCredentialSerializer, ) from bkflow.apigw.serializers.space import CreateSpaceSerializer @@ -46,9 +47,12 @@ SpaceConfigHandler, SuperusersConfig, ) +from bkflow.space.credential.scope_validator import filter_credentials_by_scope from bkflow.space.exceptions import SpaceConfigDefaultValueNotExists from bkflow.space.models import ( Credential, + CredentialScope, + CredentialScopeLevel, CredentialType, Space, SpaceConfig, @@ -61,14 +65,14 @@ ) from bkflow.space.serializers import ( CredentialBaseQuerySerializer, - CredentialSerializer, + CredentialScopeSerializer, SpaceConfigBaseQuerySerializer, SpaceConfigBatchApplySerializer, SpaceConfigSerializer, SpaceSerializer, ) from bkflow.utils.api_client import ApiGwClient, HttpRequestResult -from bkflow.utils.mixins import BKFLOWDefaultPagination +from bkflow.utils.mixins import BKFLOWDefaultPagination, BKFlowOrderingFilter from bkflow.utils.permissions import AdminPermission, AppInternalPermission from bkflow.utils.views import AdminModelViewSet, SimpleGenericViewSet @@ -89,6 +93,20 @@ class CredentialViewSet(AdminModelViewSet): filter_backends = [DjangoFilterBackend] filter_class = CredentialFilterSet + def get_queryset(self): + """根据作用域过滤凭证""" + queryset = super().get_queryset() + + # 从查询参数获取作用域信息 + scope_type = self.request.query_params.get("scope_type") + scope_value = self.request.query_params.get("scope_value") + + # 如果提供了作用域信息,过滤凭证 + if scope_type or scope_value: + queryset = filter_credentials_by_scope(queryset, scope_type, scope_value) + + return queryset + @action(detail=False, methods=["GET"]) def get_api_gateway_credential(self, request, *args, **kwargs): space_id = request.query_params.get("space_id") @@ -326,6 +344,9 @@ class CredentialConfigAdminViewSet(ModelViewSet, SimpleGenericViewSet): serializer_class = CredentialSerializer permission_classes = [AdminPermission | SpaceSuperuserPermission] pagination_class = BKFLOWDefaultPagination + filter_backends = [DjangoFilterBackend, BKFlowOrderingFilter] + ordering_fields = ["id", "name", "type", "create_at", "update_at"] + ordering = ["-create_at"] # 默认按创建时间倒序 def get_object(self): serializer = CredentialBaseQuerySerializer(data=self.request.query_params) @@ -344,6 +365,39 @@ def get_queryset(self): queryset = queryset.filter(space_id=space_id, is_deleted=False) return queryset + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(self.fill_credential_scopes(serializer.data)) + + def fill_credential_scopes(self, credential_data): + """ + 填充凭证作用域 + """ + scopes = CredentialScope.objects.filter(credential_id=credential_data["id"]) + credential_data["scopes"] = CredentialScopeSerializer(scopes, many=True).data + return credential_data + + def update_scopes(self, credential, scope_level, scopes, update=False): + """ + 更新凭证作用域 + """ + # 创建凭证作用域 + if scope_level == CredentialScopeLevel.PART.value: + if update: + CredentialScope.objects.filter(credential_id=credential.id).delete() + + if scopes: + scope_objects = [ + CredentialScope( + credential_id=credential.id, + scope_type=scope.get("scope_type"), + scope_value=scope.get("scope_value"), + ) + for scope in scopes + ] + CredentialScope.objects.bulk_create(scope_objects) + def create(self, request, *args, **kwargs): credential_serializer = CreateCredentialSerializer(data=request.data) credential_serializer.is_valid(raise_exception=True) @@ -352,21 +406,28 @@ def create(self, request, *args, **kwargs): serializer = CredentialBaseQuerySerializer(data=self.request.query_params) serializer.is_valid(raise_exception=True) space_id = serializer.validated_data.get("space_id") + try: - credential = Credential.create_credential( - space_id=space_id, - name=credential_data["name"], - type=credential_data["type"], - content=credential_data["content"], - creator=request.user.username, - desc=credential_data.get("desc"), - ) + with transaction.atomic(): + credential = Credential.create_credential( + space_id=space_id, + name=credential_data["name"], + type=credential_data["type"], + content=credential_data["content"], + creator=request.user.username, + desc=credential_data.get("desc"), + scope_level=credential_data.get("scope_level"), + ) + self.update_scopes( + credential, credential_data.get("scope_level"), credential_data.get("scopes"), update=False + ) except DatabaseError as e: err_msg = f"创建凭证失败 {str(e)}" logger.error(err_msg) return Response(exception=True, data={"detail": err_msg}) + response_serializer = CredentialSerializer(credential) - return Response(response_serializer.data, status=status.HTTP_201_CREATED) + return Response(self.fill_credential_scopes(response_serializer.data), status=status.HTTP_201_CREATED) def partial_update(self, request, *args, **kwargs): try: @@ -379,13 +440,18 @@ def partial_update(self, request, *args, **kwargs): serializer = UpdateCredentialSerializer(data=request.data) serializer.is_valid(raise_exception=True) - for attr, value in serializer.validated_data.items(): - setattr(instance, attr, value) - - instance.updated_by = request.user.username - updated_keys = list(serializer.validated_data.keys()) + ["updated_by", "update_at"] try: - instance.save(update_fields=updated_keys) + with transaction.atomic(): + # 更新凭证基本信息 + scopes_data = serializer.validated_data.pop("scopes", None) + for attr, value in serializer.validated_data.items(): + setattr(instance, attr, value) + + instance.updated_by = request.user.username + updated_keys = list(serializer.validated_data.keys()) + ["updated_by", "update_at"] + instance.save(update_fields=updated_keys) + + self.update_scopes(instance, serializer.validated_data.get("scope_level"), scopes_data, update=True) except DatabaseError as e: err_msg = f"更新凭证失败 {str(e)}" logger.error(err_msg) @@ -396,8 +462,10 @@ def partial_update(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs): try: - instance = self.get_object() - instance.hard_delete() + with transaction.atomic(): + instance = self.get_object() + CredentialScope.objects.filter(credential_id=instance.id).delete() + instance.hard_delete() except Credential.DoesNotExist as e: err_msg = f"删除凭证不存在 {str(e)}" logger.error(err_msg) diff --git a/bkflow/task/serializers.py b/bkflow/task/serializers.py index 24fe6bb1d5..de8de7255e 100644 --- a/bkflow/task/serializers.py +++ b/bkflow/task/serializers.py @@ -77,8 +77,10 @@ def validate(self, value): raise serializers.ValidationError(str(e)) constants = value.pop("constants", {}) + credentials = value.pop("credentials", {}) pipeline_tree = value.get("pipeline_tree") try: + # 处理constants for key, c_value in constants.items(): if key not in pipeline_tree.get("constants", {}): continue @@ -86,6 +88,12 @@ def validate(self, value): meta = copy.deepcopy(pipeline_tree["constants"][key]) pipeline_tree["constants"][key]["meta"] = meta pipeline_tree["constants"][key]["value"] = c_value + + # 处理credentials - 如果有credentials参数但pipeline_tree中还没有credentials字段,添加它 + # 注意:在apigw视图中已经解析了credentials,这里只是确保它们被保留 + if credentials and "credentials" not in pipeline_tree: + pipeline_tree["credentials"] = credentials + standardize_pipeline_node_name(pipeline_tree) validate_web_pipeline_tree(pipeline_tree) except PipelineException as e: diff --git a/bkflow/template/views/template.py b/bkflow/template/views/template.py index 216a3a23a4..1d816d29f2 100644 --- a/bkflow/template/views/template.py +++ b/bkflow/template/views/template.py @@ -32,6 +32,7 @@ from rest_framework.viewsets import GenericViewSet from webhook.signals import event_broadcast_signal +from bkflow.apigw.serializers.credential import CredentialSerializer from bkflow.apigw.serializers.task import ( CreateMockTaskWithPipelineTreeSerializer, CreateTaskSerializer, @@ -59,8 +60,9 @@ UniformApiConfig, UniformAPIConfigHandler, ) +from bkflow.space.credential.scope_validator import filter_credentials_by_scope from bkflow.space.exceptions import SpaceConfigDefaultValueNotExists -from bkflow.space.models import SpaceConfig +from bkflow.space.models import Credential, SpaceConfig from bkflow.space.permissions import SpaceSuperuserPermission from bkflow.space.utils import build_default_pipeline_tree_with_space_id from bkflow.template.exceptions import AnalysisConstantsRefException @@ -669,6 +671,31 @@ def rollback_template(self, request, *args, **kwargs): draft_template = instance.update_draft_snapshot(pipeline_tree, request.user.username, version) return Response(data=draft_template.data) + @swagger_auto_schema( + method="get", + operation_description="获取流程有权限的凭证列表", + ) + @action(methods=["GET"], detail=True, url_path="credentials") + def credentials(self, request, *args, **kwargs): + """ + 获取当前流程有权限的凭证列表 + 根据 Template 的 scope_type 和 scope_value 来过滤凭证 + """ + template = self.get_object() + + # 获取当前空间下的所有凭证 + credentials_queryset = Credential.objects.filter(space_id=template.space_id, is_deleted=False) + + # 根据模板的作用域过滤凭证 + filtered_credentials = filter_credentials_by_scope( + credentials_queryset, template.scope_type, template.scope_value + ) + + # 序列化凭证数据 + serializer = CredentialSerializer(filtered_credentials, many=True) + + return Response({"results": serializer.data, "count": len(serializer.data)}) + @method_decorator(login_exempt, name="dispatch") class TemplateInternalViewSet(BKFLOWCommonMixin, mixins.RetrieveModelMixin, SimpleGenericViewSet): diff --git a/bkflow/utils/crypt.py b/bkflow/utils/crypt.py new file mode 100644 index 0000000000..c27a2a558a --- /dev/null +++ b/bkflow/utils/crypt.py @@ -0,0 +1,63 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 base64 + +from Crypto.Cipher import AES +from django.conf import settings + + +class BaseCrypt: + _bk_crypt = False + + # KEY 和 IV 的长度需等于16 + ROOT_KEY = b"TencentBkApp-Key" + ROOT_IV = b"TencentBkApp--Iv" + encoding = None + + def __init__(self, instance_key=settings.SECRET_KEY, encoding="utf-8"): + self.INSTANCE_KEY = instance_key + self.encoding = encoding + + def encrypt(self, plaintext): + """ + 加密 + :param plaintext: 需要加密的内容 + :return: + """ + decrypt_key = self.__parse_key() + if isinstance(plaintext, str): + plaintext = plaintext.encode(encoding=self.encoding) + secret_txt = AES.new(decrypt_key, AES.MODE_CFB, self.ROOT_IV).encrypt(plaintext) + return base64.b64encode(secret_txt).decode("utf-8") + + def decrypt(self, ciphertext): + """ + 解密 + :param ciphertext: 需要解密的内容 + :return: + """ + decrypt_key = self.__parse_key() + # 先解base64 + secret_txt = base64.b64decode(ciphertext) + # 再解对称加密 + plain = AES.new(decrypt_key, AES.MODE_CFB, self.ROOT_IV).decrypt(secret_txt) + return plain.decode(encoding=self.encoding) + + def __parse_key(self): + return self.INSTANCE_KEY[:24].encode() diff --git a/bkflow/utils/models.py b/bkflow/utils/models.py index 1c0873c68a..50e02691a4 100644 --- a/bkflow/utils/models.py +++ b/bkflow/utils/models.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -20,10 +19,12 @@ import hashlib import json +from django.conf import settings from django.db import models from django.utils.translation import ugettext_lazy as _ from pipeline.models import CompressJSONField, SnapshotManager +from bkflow.utils.crypt import BaseCrypt from bkflow.utils.md5 import compute_pipeline_md5 @@ -44,7 +45,7 @@ def delete(self, using=None, keep_parents=False): self.save() def hard_delete(self): - super(CommonModel, self).delete() + super().delete() class CommonSnapshotManager(SnapshotManager): @@ -82,3 +83,129 @@ def has_change(self, data): """ md5 = compute_pipeline_md5(data) return md5, self.md5sum != md5 + + +class BaseSecretField: + """ + Secret字段:入库加密, 出库解密 + """ + + _crypt = BaseCrypt(instance_key=settings.PRIVATE_SECRET) + + def from_db_value(self, value, expression, connection): + if not value: + return None + return self._crypt.decrypt(value) + + def to_python(self, value): + if value is None: + return value + return self._crypt.encrypt(value) + + def get_prep_value(self, value): + if value is None: + return value + return self._crypt.encrypt(value) + + +class SecretField(BaseSecretField, models.CharField): + """ + Secret字段:入库加密, 出库解密 + """ + + def from_db_value(self, value, expression, connection): + return super().from_db_value(value, expression, connection) + + def to_python(self, value): + return super().to_python(value) + + def get_prep_value(self, value): + return super().get_prep_value(value) + + +class SecretTextField(BaseSecretField, models.TextField): + """ + Secret字段:入库加密, 出库解密 + """ + + def from_db_value(self, value, expression, connection): + return super().from_db_value(value, expression, connection) + + def to_python(self, value): + return super().to_python(value) + + def get_prep_value(self, value): + return super().get_prep_value(value) + + +class SecretSingleJsonField(models.JSONField): + """ + Secret JSON 字段:只支持单层 JSON 结构,对每个 key 的 value 进行加密/解密 + + 示例: + {"username": "admin", "password": "secret123"} + 存储时:{"username": "encrypted_admin", "password": "encrypted_secret123"} + """ + + _crypt = BaseCrypt(instance_key=settings.PRIVATE_SECRET) + + def from_db_value(self, value, expression, connection): + """ + 从数据库读取时,解密 JSON 中所有 value + + :param value: 数据库中的加密 JSON 数据 + :param expression: 查询表达式 + :param connection: 数据库连接 + :return: 解密后的 JSON 数据 + """ + if not value: + return value + + # 先调用父类方法获取 JSON 对象 + value = super().from_db_value(value, expression, connection) + + if not isinstance(value, dict): + return value + + # 解密每个 value + decrypted_data = {} + for key, val in value.items(): + if val is not None: + try: + decrypted_data[key] = self._crypt.decrypt(val) + except Exception: + # 如果解密失败,返回原值(可能是未加密的旧数据) + decrypted_data[key] = val + else: + decrypted_data[key] = val + + return decrypted_data + + def get_prep_value(self, value): + """ + 写入数据库时,加密 JSON 中所有 value + + :param value: 原始 JSON 数据 + :return: 加密后的 JSON 数据 + """ + if value is None: + return value + + if not isinstance(value, dict): + raise ValueError("SecretSingleJsonField 只支持字典类型的数据") + + # 检查是否为单层 JSON + for key, val in value.items(): + if isinstance(val, (dict, list)): + raise ValueError("SecretSingleJsonField 只支持单层 JSON 结构,不支持嵌套对象或数组") + + # 加密每个 value + encrypted_data = {} + for key, val in value.items(): + if val is not None and isinstance(val, str): + encrypted_data[key] = self._crypt.encrypt(val) + else: + encrypted_data[key] = val + + # 调用父类方法处理 JSON 序列化 + return super().get_prep_value(encrypted_data) diff --git a/bkflow/utils/serializer.py b/bkflow/utils/serializer.py new file mode 100644 index 0000000000..3d405cbf3f --- /dev/null +++ b/bkflow/utils/serializer.py @@ -0,0 +1,169 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 +from functools import wraps + +from django.core.handlers.wsgi import WSGIRequest +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers +from rest_framework.request import Request + +from bkflow.exceptions import ValidationError + + +def params_valid(serializer: serializers.Serializer, add_params: bool = True): + """参数校验装饰器 + + :param serializer: serializer类 + :param add_params: 是否将校验后的参数添加到request.cleaned_params中 + :return: 参数校验装饰器 + """ + + def decorator(view_func): + @wraps(view_func) + def wrapper(*args, **kwargs): + # 获得Django的request对象 + _request = kwargs.get("request") + + if not _request: + for arg in args: + if isinstance(arg, (Request, WSGIRequest)): + _request = arg + break + + if not _request: + raise ValidationError(_("该装饰器只允许用于Django的View函数(包括普通View函数和Class-base的View函数)")) + + # 校验request中的参数 + params = {} + if _request.method in ["GET"]: + if isinstance(_request, Request): + params = _request.query_params + else: + params = _request.GET + elif _request.is_ajax(): + if isinstance(_request, Request): + params = _request.data + else: + params = _request.json() + else: + if isinstance(_request, Request): + params = _request.data + else: + params = _request.POST + + cleaned_params = custom_params_valid(serializer=serializer, params=params) + _request.cleaned_params = cleaned_params + + # 执行实际的View逻辑 + params_add = False + try: + # 语法糖,使用这个decorator的话可直接从view中获得参数的字典 + if "params" not in kwargs and add_params: + kwargs["params"] = cleaned_params + params_add = True + except TypeError: + if params_add: + del kwargs["params"] + resp = view_func(*args, **kwargs) + return resp + + return wrapper + + return decorator + + +def format_serializer_errors(errors: dict, fields: dict, params: dict, prefix: str = " "): + """格式化序列化器错误信息 + + :param errors: 错误信息 + :param fields: 序列化器字段 + :param params: 参数 + :return: 格式化后的错误信息 + """ + message = _("参数校验失败:{wrap}").format(wrap="\n") if prefix == " " else "\n" + for key, field_errors in list(errors.items()): + sub_message = "" + label = key + if key not in fields: + sub_message = json.dumps(field_errors, ensure_ascii=False) + else: + field = fields[key] + label = field.label or field.field_name + if ( + hasattr(field, "child") + and isinstance(field_errors, list) + and len(field_errors) > 0 + and not isinstance(field_errors[0], str) + ): + for index, sub_errors in enumerate(field_errors): + if sub_errors: + sub_format = format_serializer_errors( + sub_errors, field.child.fields, params, prefix=prefix + " " + ) + # return sub_format + sub_message += _("{wrap}{prefix}第{index}项:").format( + wrap="\n", + prefix=prefix + " ", + index=index + 1, + ) + sub_message += sub_format + else: + if isinstance(field_errors, dict): + if hasattr(field, "child"): + sub_foramt = format_serializer_errors( + field_errors, field.child.fields, params, prefix=prefix + " " + ) + else: + sub_foramt = format_serializer_errors(field_errors, field.fields, params, prefix=prefix + " ") + sub_message += sub_foramt + elif isinstance(field_errors, list): + for index in range(len(field_errors)): + field_errors[index] = field_errors[index].format(**{key: params.get(key, "")}) + sub_message += "{index}.{error}".format(index=index + 1, error=field_errors[index]) + sub_message += "\n" + # 对使用 Validate() 时 label == 'non_field_errors' 的特殊情况做处理 + if label == "non_field_errors": + message += "{prefix} {message}".format(prefix=prefix, message=sub_message) + else: + message += "{prefix}{label}: {message}".format(prefix=prefix, label=label, message=sub_message) + return message + + +def custom_params_valid(serializer: serializers.Serializer, params: dict, many: bool = False): + """校验参数 + + :param serializer: 校验器 + :param params: 原始参数 + :param many: 是否为多个参数 + :return: 校验通过的参数 + """ + _serializer = serializer(data=params, many=many) + try: + _serializer.is_valid(raise_exception=True) + except serializers.ValidationError as e: # pylint: disable=broad-except # noqa + try: + message = format_serializer_errors(_serializer.errors, _serializer.fields, params) + except Exception as e: # pylint: disable=broad-except + if isinstance(e.message, str): + message = e.message + else: + message = _("参数校验失败,详情请查看返回的errors") + raise ValidationError(message=message, errors=_serializer.errors) + return list(_serializer.validated_data) if many else dict(_serializer.validated_data) diff --git a/config/default.py b/config/default.py index 7997f63684..866f783945 100644 --- a/config/default.py +++ b/config/default.py @@ -411,6 +411,9 @@ def handler_filter_injection(filters: list): # 允许 static、openapi 路径跨域访问 CORS_URLS_REGEX = r"^/(static\/components|openapi)/.*$" +# 加密字段配置 +PRIVATE_SECRET = env.PRIVATE_SECRET or SECRET_KEY + """ 以下为框架代码 请勿修改 """ diff --git a/env.py b/env.py index fde9f5dea1..b2fab36ee7 100644 --- a/env.py +++ b/env.py @@ -179,5 +179,9 @@ PIPELINE_RERUN_MAX_TIMES = int(os.getenv("PIPELINE_RERUN_MAX_TIMES", 100)) BAMBOO_DJANGO_ERI_NODE_RERUN_LIMIT = int(os.getenv("BAMBOO_DJANGO_ERI_NODE_RERUN_LIMIT", 100)) +# 模板最大递归次数 TEMPLATE_MAX_RECURSIVE_NUMBER = int(os.getenv("TEMPLATE_MAX_RECURSIVE_NUMBER", 10)) REQUEST_RETRY_NUMBER = int(os.getenv("REQUEST_RETRY_NUMBER", 3)) + +# 加密字段配置 +PRIVATE_SECRET = os.getenv("PRIVATE_SECRET", "") diff --git a/frontend/src/api/ajax.js b/frontend/src/api/ajax.js index 14ab28e961..af122c0c84 100644 --- a/frontend/src/api/ajax.js +++ b/frontend/src/api/ajax.js @@ -48,7 +48,6 @@ axios.interceptors.response.use( } const { response } = error; - console.log(response); if (response.data.message) { response.data.msg = response.data.message; } diff --git a/frontend/src/assets/images/apigw.png b/frontend/src/assets/images/apigw.png new file mode 100644 index 0000000000..fbc6b29c54 Binary files /dev/null and b/frontend/src/assets/images/apigw.png differ diff --git a/frontend/src/assets/images/apigw_access_token.png b/frontend/src/assets/images/apigw_access_token.png new file mode 100644 index 0000000000..f4fbba4ae6 Binary files /dev/null and b/frontend/src/assets/images/apigw_access_token.png differ diff --git a/frontend/src/components/common/FullCodeEditor.vue b/frontend/src/components/common/FullCodeEditor.vue index ee6e50444a..68ac42b58b 100644 --- a/frontend/src/components/common/FullCodeEditor.vue +++ b/frontend/src/components/common/FullCodeEditor.vue @@ -128,13 +128,13 @@ limitations under the License. */ text-align: right; background: #202024; .zoom-icon { - font-size: 14px; - color: #ffffff; + font-size: 12px; + color: #979ba5; cursor: pointer; } .icon-copy { - font-size: 18px; - color: #ffffff; + font-size: 14px; + color: #979ba5; cursor: pointer; } } diff --git a/frontend/src/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 1578a507a9..e6d6fd1ed2 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -926,12 +926,14 @@ const cn = { 凭证管理: '凭证管理', 内容: '内容', '凭证删除后不可恢复,确认删除?': '凭证删除后不可恢复,确认删除?', + 蓝鲸应用凭证: '蓝鲸应用凭证', + '蓝鲸 Access Token 凭证': '蓝鲸 Access Token 凭证', + 'Basic Auth': 'Basic Auth', 值类型: '值类型', 表单模式: '表单模式', json模式: 'json模式', 插件名称: '插件名称', 管理员: '管理员', - 授权状态: '授权状态', 使用范围: '使用范围', 授权状态修改时间: '授权状态修改时间', 授权状态修改人: '授权状态修改人', @@ -1059,6 +1061,41 @@ const cn = { 参数名不能为空: '参数名不能为空', 参数名不能重复: '参数名不能重复', '请输入值或 $ 选择变量': '请输入值或 $ 选择变量', + 查看内容: '查看内容', + 作用域: '作用域', + 自定义: '自定义', + 凭证指引: '凭证指引', + 凭证内容: '凭证内容', + 作用域值: '作用域值', + 作用域类型: '作用域类型', + 开放范围: '开放范围', + 不开放: '不开放', + 全部流程: '全部流程', + 按作用域开放: '按作用域开放', + 查看凭证获取指引: '查看凭证获取指引', + 蓝鲸应用认证: '蓝鲸应用认证', + 名称不能为空: '名称不能为空', + 至少保留一个: '至少保留一个', + bk_app_code不能为空: 'bk_app_code不能为空', + bk_app_secret不能为空: 'bk_app_secret不能为空', + access_token不能为空: 'access_token不能为空', + username不能为空: 'username不能为空', + password不能为空: 'password不能为空', + 凭证内容或者作用域存在重复的key: '存在重复的key: {value}', + 第n项凭证内容不能为空: '第{value}项凭证内容不能为空', + 第n项凭证作用域不能为空: '第{value}项凭证作用域不能为空', + '第n项凭证内容格式错误, 仅支持字母、数字、下划线、连字符': '第{value}项凭证内容格式错误, 仅支持字母、数字、下划线、连字符', + '第n项凭证作用域格式错误, 仅支持字母、数字、下划线、连字符': '第{value}项凭证作用域格式错误, 仅支持字母、数字、下划线、连字符', + '蓝鲸 Access Token 认证': '蓝鲸 Access Token 认证', + '仅支持字母、数字、下划线、连字符': '仅支持字母、数字、下划线、连字符', + '中英文字符、数字或以下字符-)().,以中英文字符、数字开头,32个字符内': '中英文字符、数字或以下字符-)().,以中英文字符、数字开头,32个字符内', + 登录态: '登录态', + 选择凭证: '选择凭证', + 请选择凭证: '请选择凭证', + 访问凭证: '访问凭证', + 选择调试凭证: '选择调试凭证', + 凭证: '凭证', + 请选择调试凭证: '请选择调试凭证', }; export default cn; diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index 9ba229c05f..2e6d3e5dbe 100644 --- a/frontend/src/config/i18n/en.js +++ b/frontend/src/config/i18n/en.js @@ -926,6 +926,10 @@ const en = { 凭证管理: 'Credential Management', 内容: 'Content', '凭证删除后不可恢复,确认删除?': 'The voucher cannot be recovered once deleted. Are you sure you want to delete it?', + 蓝鲸应用凭证: 'BK App Credential', + '蓝鲸 Access Token 凭证': 'BK Access Token', + 'Basic Auth': 'Basic Auth', + 自定义: 'Custom', 值类型: 'Value Type', 表单模式: 'Form Mode', json模式: 'JSON Mode', @@ -1057,6 +1061,40 @@ const en = { 参数名不能为空: 'Parameter name cannot be empty', 参数名不能重复: 'Parameter name cannot be duplicated', '请输入值或 $ 选择变量': 'Enter value or $ to select variable', + 查看内容: 'View content', + 作用域: 'Scope', + 凭证指引: 'Credential guidelines', + 凭证内容: 'Credential content', + 作用域值: 'Scope value', + 作用域类型: 'Scope type', + 开放范围: 'Open scope', + 不开放: 'Not open', + 全部流程: 'All processes', + 按作用域开放: 'Open by scope', + 查看凭证获取指引: 'View credential acquisition guide', + 蓝鲸应用认证: 'BlueKing App Authentication', + 至少保留一个: 'Keep at least one', + 名称不能为空: 'The name cannot be empty', + bk_app_code不能为空: 'bk_app_code cannot be empty', + bk_app_secret不能为空: 'bk_app_secret cannot be empty', + access_token不能为空: 'access_token cannot be empty', + username不能为空: 'username cannot be empty', + password不能为空: 'password cannot be empty', + 凭证内容或者作用域存在重复的key: 'There are duplicate keys: {value}', + 第n项凭证内容不能为空: 'The content of the {value} credential cannot be empty', + 第n项凭证作用域不能为空: 'The scope of the {value} credential cannot be empty', + '第n项凭证内容格式错误, 仅支持字母、数字、下划线、连字符': 'The format of the {value} credential content is incorrect, only supporting letters, numbers, underscores, and hyphens', + '第n项凭证作用域格式错误, 仅支持字母、数字、下划线、连字符': 'The format of the {value} credential scope is incorrect, only supporting letters, numbers, underscores, and hyphens', + '蓝鲸 Access Token 认证': 'BlueKing Access Token Authentication', + '仅支持字母、数字、下划线、连字符': 'Only supports letters, numbers, underscores, and hyphens', + '中英文字符、数字或以下字符-)().,以中英文字符、数字开头,32个字符内': 'Chinese and English characters, numbers, or the following characters -) () Starting with Chinese and English characters or numbers, within 32 characters', + 登录态: 'Login state', + 选择凭证: 'Select credential', + 请选择凭证: 'Please select credential', + 访问凭证: 'Access credential', + 选择调试凭证: 'Select debug credential', + 凭证: 'Credential', + 请选择调试凭证: 'Please select debug credential', }; export default en; diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index 039873ec8d..3d09dbed70 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -165,6 +165,42 @@ const COLOR_BLOCK_LIST = [ }, ]; +// 凭证类型 +const CREDENTIAL_TYPE_LIST = [ + { + text: i18n.t('蓝鲸应用认证'), + value: 'BK_APP', + }, + { + text: i18n.t('蓝鲸 Access Token 认证'), + value: 'BK_ACCESS_TOKEN', + }, + { + text: 'Basic Auth', + value: 'BASIC_AUTH', + }, + { + text: i18n.t('自定义'), + value: 'CUSTOM', + }, +]; + +// 凭证开放范围 +const CREDENTIAL_OPEN_SCOPE_LIST = [ + { + text: i18n.t('全部流程'), + value: 'all', + }, + { + text: i18n.t('按作用域开放'), + value: 'part', + }, + { + text: i18n.t('不开放'), + value: 'none', + }, +]; + const NAME_REG = /^[^'"‘’“”$<>]+$/; const PACKAGE_NAME_REG = /^[^\d][\w]*?$/; // celery的crontab时间表达式正则表达式(分钟 小时 星期 日 月)(以空格分割) @@ -176,7 +212,19 @@ const URL_REG = new RegExp('^(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[- /* eslint-enable */ export { - TASK_STATE_DICT, NODE_DICT, SYSTEM_GROUP_ICON, BK_PLUGIN_ICON, NAME_REG, - INVALID_NAME_CHAR, PACKAGE_NAME_REG, URL_REG, PERIODIC_REG, STRING_LENGTH, - LABEL_COLOR_LIST, DARK_COLOR_LIST, TASK_CATEGORIES, COLOR_BLOCK_LIST, + TASK_STATE_DICT, + NODE_DICT, + SYSTEM_GROUP_ICON, + BK_PLUGIN_ICON, NAME_REG, + INVALID_NAME_CHAR, + PACKAGE_NAME_REG, + URL_REG, + PERIODIC_REG, + STRING_LENGTH, + LABEL_COLOR_LIST, + DARK_COLOR_LIST, + TASK_CATEGORIES, + COLOR_BLOCK_LIST, + CREDENTIAL_TYPE_LIST, + CREDENTIAL_OPEN_SCOPE_LIST, }; diff --git a/frontend/src/scss/mixins/credentialScope.scss b/frontend/src/scss/mixins/credentialScope.scss new file mode 100644 index 0000000000..bdc1f3907c --- /dev/null +++ b/frontend/src/scss/mixins/credentialScope.scss @@ -0,0 +1,70 @@ +@mixin required-asterisk { + height: 8px; + line-height: 1; + content: "*"; + color: #ea3636; + font-size: 12px; + display: inline-block; + vertical-align: middle; + position: absolute; + top: 50%; + transform: translate(3px, -50%); +} + +.credential-slider { + ::v-deep .credential-slider-content { + padding: 24px 40px; + + .plus-shape-icon, + .minus-shape-icon { + color: #c4c6cc; + margin-right: 18px; + cursor: pointer; + + &.is-disabled { + color: #eaebf0; + cursor: not-allowed; + } + } + + .credential-content-table { + thead { + th:not(.is-last) { + .bk-table-header-label { + &::after { + @include required-asterisk; + } + } + } + } + + tbody { + td:not(.is-last) { + .cell { + padding: 0; + + .bk-form-input { + height: 42px; + border: none; + } + } + } + } + } + + .error-tip { + font-size: 12px; + color: #ea3636; + line-height: 18px; + margin: 2px 0 0; + word-break: break-all; + } + } + + .credential-slider-footer { + padding-left: 40px; + .bk-button { + min-width: 88px; + } + } +} diff --git a/frontend/src/store/modules/credentialConfig.js b/frontend/src/store/modules/credentialConfig.js index 78165eb2e3..9f7114a447 100644 --- a/frontend/src/store/modules/credentialConfig.js +++ b/frontend/src/store/modules/credentialConfig.js @@ -14,6 +14,14 @@ export default { createCredential({}, params) { return axios.post(`api/space/admin/credential_config/?space_id=${params.space_id}`, params).then(response => response.data); }, + /** + * 凭证详情接口 + * @param {String} params.spaceId 空间id + * @param {String} params.id 详情id + **/ + getCredential({}, params) { + return axios.get(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); + }, updateCredential({}, params) { return axios.patch(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); }, diff --git a/frontend/src/store/modules/template.js b/frontend/src/store/modules/template.js index 8519ca7859..b988e59be6 100644 --- a/frontend/src/store/modules/template.js +++ b/frontend/src/store/modules/template.js @@ -361,7 +361,6 @@ const template = { scope_value, }; state.triggers = triggers; - state.canvas_mode = pipelineData.canvas_mode; this.commit('template/setPipelineTree', pipelineData); }, @@ -1256,6 +1255,10 @@ const template = { const { templateId, space_id, version } = data; return axios.post(`/api/template/${templateId}/rollback_template/`, { version, space_id }).then(response => response.data); }, + // 获取凭证列表 + getCredentialList({}, data) { + return axios.get(`/api/space/admin/credential_config/`, {params: data }).then(response => response.data); + }, }, getters: { // 获取所有模板数据 diff --git a/frontend/src/views/admin/Space/Credential/CredentialDialog.vue b/frontend/src/views/admin/Space/Credential/CredentialDialog.vue deleted file mode 100644 index 6a8dd03ff3..0000000000 --- a/frontend/src/views/admin/Space/Credential/CredentialDialog.vue +++ /dev/null @@ -1,163 +0,0 @@ - - diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialContentDialog.vue b/frontend/src/views/admin/Space/Credential/components/CredentialContentDialog.vue new file mode 100644 index 0000000000..8a9bcbb2fb --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/components/CredentialContentDialog.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue b/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue new file mode 100644 index 0000000000..856fd55f99 --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue @@ -0,0 +1,169 @@ + + + diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialSlider.vue b/frontend/src/views/admin/Space/Credential/components/CredentialSlider.vue new file mode 100644 index 0000000000..743b20d8fd --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/components/CredentialSlider.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/frontend/src/views/admin/Space/Credential/components/ImageViewer.vue b/frontend/src/views/admin/Space/Credential/components/ImageViewer.vue new file mode 100644 index 0000000000..fec998fc9a --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/components/ImageViewer.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/views/admin/Space/Credential/index.vue b/frontend/src/views/admin/Space/Credential/index.vue index 2cdcfd372e..e9624580f2 100644 --- a/frontend/src/views/admin/Space/Credential/index.vue +++ b/frontend/src/views/admin/Space/Credential/index.vue @@ -4,219 +4,348 @@ class="mb20" theme="primary" :disabled="listLoading || !spaceId" - @click="isDialogShow = true"> - {{ $t('新建') }} + @click="handleOperate('create', {})"> + {{ $t("新建") }} + @page-limit-change="handlePageLimitChange" + @filter-change="handleFilterChange" + @sort-change="handleSortChange"> + :min-width="item.min_width" + :fixed="item.fixed" + :sortable="item.sortable ?? false" + :filters="item.filters" + :filter-method="item.filterMethod" + :filter-multiple="item.filterMultiple ?? false" + show-overflow-tooltip> - + type="setting" + :tippy-options="{ zIndex: 3000 }"> +
- +
- + + + + diff --git a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue index ab3ca2f73a..ec094985ef 100644 --- a/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue +++ b/frontend/src/views/admin/Space/Template/CreateTaskSideslider.vue @@ -20,6 +20,19 @@ :maxlength="stringLength.TASK_NAME_MAX_LENGTH" :show-word-limit="true" /> + +
+ + + + + + + +
+ + + + diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue index 5a1bcbd108..fbcd619d09 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue @@ -113,6 +113,20 @@ @viewAllSubflowVerison="$emit('viewAllSubflowVerison', $event)" @changeSubNodeVersion="onChangeSubNodeVersion" /> +
+

{{ $t('访问凭证') }}

+ +
{ + if (this.nodeConfig.component.credentials[item.key]) { + item.value = this.nodeConfig.component.credentials[item.key].value; + } + return item; + }); + this.updateBasicInfo({ credentials: this.nodeConfig.component.credentials, processCredentials: backfillData }); + } // api插件json字段展示解析优化 this.handleJsonValueParse(false, paramsVal); this.inputsParamValue = paramsVal; @@ -640,6 +667,7 @@ // 第三方插件输入输出配置 async getThirdConfig(plugin, version) { try { + this.credentialLoading = true; const resp = await this.loadPluginServiceDetail({ plugin_code: plugin, plugin_version: version, @@ -654,7 +682,22 @@ const descList = desc.split('\n'); desc = descList.join('
'); } - this.updateBasicInfo({ desc }); + if (Object.prototype.hasOwnProperty.call(resp.data, 'credentials')) { + const processCredentials = []; + resp.data.credentials.forEach((item) => { + processCredentials.push({ + key: item.key, + hook: false, + need_render: true, + value: '', + description: item.description || '', + }); + }); + this.updateBasicInfo({ desc, isHaveCredentials: true, processCredentials }); + } else { + this.updateBasicInfo({ desc }); + } + this.credentialLoading = false; // 获取host const { origin } = window.location; const hostUrl = `${origin + window.SITE_URL}plugin_service/data_api/${plugin}/`; @@ -814,6 +857,7 @@ let code = ''; let desc = ''; let version = ''; + let credentials = null; // 节点已选择标准插件 if (component.code && !this.isNotExistAtomOrVersion) { // 节点插件存在 if (component.code === 'remote_plugin') { @@ -823,6 +867,7 @@ basicInfoName = resp.data.name; version = atom.version; desc = atom.desc; + credentials = resp.data.credentials || null; } else if (component.code === 'uniform_api') { code = component.code; version = component.version; @@ -858,6 +903,7 @@ autoRetry: Object.assign({}, { enable: false, interval: 0, times: 1 }, auto_retry), timeoutConfig: timeoutConfig || { enable: false, seconds: 10, action: 'forced_fail' }, executor_proxy: executorProxy ? executorProxy.split(',') : [], + credentials, }; if (component.code === 'uniform_api' && component.api_meta) { // 新版api插件中component包含api_meta字段 const { id, name, api_key: apiKey, meta_url, category = {} } = component.api_meta; @@ -1181,6 +1227,9 @@ }); this.$refs.basicInfo && this.$refs.basicInfo.validate(); // 清除节点保存报错时的错误信息 }, + onChangeCredential(val,) { + this.updateBasicInfo({ credentials: val }); + }, /** * 更新基础信息 * 填写基础信息表单,切换插件/子流程,选择插件版本,子流程更新 @@ -1419,7 +1468,6 @@ // 删除全局变量 deleteVariable(key) { const constant = this.localConstants[key]; - Object.keys(this.localConstants).forEach((key) => { const varItem = this.localConstants[key]; if (varItem.index > constant.index) { @@ -1431,7 +1479,13 @@ }, // 节点配置面板表单校验,基础信息和输入参数 validate() { - return this.$refs.basicInfo.validate().then(() => { + return this.$refs.basicInfo.validate().then(async () => { + if (this.$refs.accessCredential) { + const validations = await this.$refs.accessCredential.validate(); + if (!validations) { + return false; + }; + } if (this.$refs.inputParams) { let result = this.$refs.inputParams.validate(); // api插件额外校验json类型 @@ -1542,6 +1596,7 @@ autoRetry, timeoutConfig, executor_proxy, + credentials, } = this.basicInfo; // 设置标准插件节点在 activity 的 component.data 值 let data = {}; @@ -1556,6 +1611,9 @@ data, version: this.isThirdParty ? '1.0.0' : version, }; + if (credentials) { + component.credentials = credentials; + } if (this.isApiPlugin && this.basicInfo.pluginId) { // 新版api插件中component包含pluginId字段 const { pluginId, name, metaUrl, groupId, groupName, apiKey } = this.basicInfo; component.api_meta = { diff --git a/frontend/src/views/template/TemplateEdit/TemplateSetting/TabGlobalVariables/VariableEdit.vue b/frontend/src/views/template/TemplateEdit/TemplateSetting/TabGlobalVariables/VariableEdit.vue index 130e72d680..abd90a3e87 100644 --- a/frontend/src/views/template/TemplateEdit/TemplateSetting/TabGlobalVariables/VariableEdit.vue +++ b/frontend/src/views/template/TemplateEdit/TemplateSetting/TabGlobalVariables/VariableEdit.vue @@ -565,7 +565,7 @@ const classify = customType ? 'variable' : 'component'; this.atomConfigLoading = true; this.atomTypeKey = atom; - if (atomFilter.isConfigExists(atom, version, this.atomFormConfig)) { + if (atomFilter.isConfigExists(atom, version, this.atomFormConfig)) { // 判断配置文件是否已经获取过 this.getRenderConfig(); this.$nextTick(() => { this.atomConfigLoading = false; @@ -659,7 +659,6 @@ error_message: i18n.t('默认值不符合正则规则'), }); } - this.renderConfig = [config]; if (!this.variableData.key) { // 新建变量 this.theEditingData.value = atomFilter.getFormItemDefaultValue(this.renderConfig); @@ -768,7 +767,7 @@ this.theEditingData.is_meta = data.type === 'meta'; this.metaTag = data.meta_tag; - const validateSet = this.getValidateSet(); + const validateSet = this.getValidateSet(); // 获取校验规则 this.$set(this.renderOption, 'validateSet', validateSet); this.getAtomConfig(); }, diff --git a/frontend/src/views/template/TemplateMock/MockExecute/index.vue b/frontend/src/views/template/TemplateMock/MockExecute/index.vue index 42c1305dd2..d6b109c0d1 100644 --- a/frontend/src/views/template/TemplateMock/MockExecute/index.vue +++ b/frontend/src/views/template/TemplateMock/MockExecute/index.vue @@ -4,6 +4,30 @@ class="mock-execute">
+ +

{{ $t('填写调试入参') }} @@ -148,11 +172,15 @@ rules: {}, unMockNodes: [], unMockExpend: false, + // mockCredentialValue: '', + // credentialList: [], }; }, computed: { ...mapState({ creator: state => state.username, + // spaceId: state => state.template.spaceId, + // scopeInfo: state => state.template.scopeInfo, }), isUnreferencedShow() { if (this.isLoading) return false; @@ -163,14 +191,22 @@ }, created() { this.loadData(); + // this.loadCredentialList(); }, methods: { ...mapActions('template/', [ 'gerTemplatePreviewData', + // 'getCredentialList' ]), ...mapActions('task/', [ 'createMockTask', ]), + // async loadCredentialList() { + // this.listLoading = true; + // const res = await this.getCredentialList({ space_id: this.spaceId, ...this.scopeInfo }); + // this.credentialList = res.data.results || []; + // this.listLoading = false; + // }, async loadData() { try { const resp = await this.gerTemplatePreviewData({ @@ -313,6 +349,8 @@ } return isEqual; }, + // onChangeMockCredential() { + // }, }, }; @@ -344,6 +382,15 @@ font-weight: Bold; margin-bottom: 16px; } + .credential-wrap { + flex: 1; + display: flex; + flex-direction: column; + padding: 12px 24px 25px 24px; + margin-bottom: 16px; + background: #fff; + box-shadow: 0 2px 4px 0 #1919290d; + } .variable-wrap { flex: 1; display: flex; diff --git a/tests/interface.env b/tests/interface.env index f9d03d3394..341cfe6a41 100644 --- a/tests/interface.env +++ b/tests/interface.env @@ -17,4 +17,5 @@ SKIP_APIGW_CHECK=1 APP_INTERNAL_TOKEN=123456 ENABLE_BK_PLUGIN_AUTHORIZATION=1 +PRIVATE_SECRET=test_secret_key_32_bytes_long! diff --git a/tests/interface/credential/__init__.py b/tests/interface/credential/__init__.py new file mode 100644 index 0000000000..732d2b8d52 --- /dev/null +++ b/tests/interface/credential/__init__.py @@ -0,0 +1,18 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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. +""" diff --git a/tests/interface/credential/test_credential.py b/tests/interface/credential/test_credential.py new file mode 100644 index 0000000000..c217056e50 --- /dev/null +++ b/tests/interface/credential/test_credential.py @@ -0,0 +1,218 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 rest_framework import serializers + +from bkflow.space.credential.basic_auth import BasicAuthCredential +from bkflow.space.credential.bk_access_token import BkAccessTokenCredential +from bkflow.space.credential.custom import CustomCredential +from bkflow.space.credential.dispatcher import CredentialDispatcher +from bkflow.space.exceptions import CredentialTypeNotSupport + + +class TestBkAccessTokenCredential: + """测试蓝鲸登录态凭证""" + + def test_validate_valid_data(self): + """测试验证有效数据""" + data = {"access_token": "valid_token_123"} + credential = BkAccessTokenCredential(data=data) + validated = credential.validate_data() + assert validated == data + + def test_validate_invalid_data_missing_field(self): + """测试验证缺少必填字段""" + data = {} + credential = BkAccessTokenCredential(data=data) + with pytest.raises(serializers.ValidationError): + credential.validate_data() + + def test_validate_invalid_data_all_asterisks(self): + """测试验证全为星号的 token""" + data = {"access_token": "*********"} + credential = BkAccessTokenCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "不应全为 * 字符" in str(exc_info.value) + + def test_value_returns_original_data(self): + """测试返回原始值""" + data = {"access_token": "test_token"} + credential = BkAccessTokenCredential(data=data) + assert credential.value() == data + + def test_display_value_masks_token(self): + """测试脱敏显示""" + data = {"access_token": "secret_token"} + credential = BkAccessTokenCredential(data=data) + display = credential.display_value() + assert display["access_token"] == "*********" + + +class TestBasicAuthCredential: + """测试用户名+密码凭证""" + + def test_validate_valid_data(self): + """测试验证有效数据""" + data = {"username": "admin", "password": "secret123"} + credential = BasicAuthCredential(data=data) + validated = credential.validate_data() + assert validated == data + + def test_validate_invalid_data_missing_username(self): + """测试验证缺少用户名""" + data = {"password": "secret123"} + credential = BasicAuthCredential(data=data) + with pytest.raises(serializers.ValidationError): + credential.validate_data() + + def test_validate_invalid_data_missing_password(self): + """测试验证缺少密码""" + data = {"username": "admin"} + credential = BasicAuthCredential(data=data) + with pytest.raises(serializers.ValidationError): + credential.validate_data() + + def test_validate_invalid_password_all_asterisks(self): + """测试验证全为星号的密码""" + data = {"username": "admin", "password": "***"} + credential = BasicAuthCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "不应全为 * 字符" in str(exc_info.value) + + def test_display_value_masks_password(self): + """测试脱敏显示""" + data = {"username": "admin", "password": "secret123"} + credential = BasicAuthCredential(data=data) + display = credential.display_value() + assert display["username"] == "admin" + assert display["password"] == "*********" + + +class TestCustomCredential: + """测试自定义凭证""" + + def test_validate_valid_data(self): + """测试验证有效数据""" + data = {"api_key": "key123", "api_secret": "secret456"} + credential = CustomCredential(data=data) + validated = credential.validate_data() + assert validated == data + + def test_validate_empty_data(self): + """测试验证空数据""" + data = {} + credential = CustomCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "不能为空" in str(exc_info.value) + + def test_validate_non_dict_data(self): + """测试验证非字典类型数据""" + data = "not_a_dict" + credential = CustomCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "必须是字典类型" in str(exc_info.value) + + def test_validate_nested_dict(self): + """测试验证嵌套字典(不支持)""" + data = {"key1": {"nested": "value"}} + credential = CustomCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "必须是字符串类型" in str(exc_info.value) + + def test_validate_array_value(self): + """测试验证数组值(不支持)""" + data = {"key1": ["item1", "item2"]} + credential = CustomCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "必须是字符串类型" in str(exc_info.value) + + def test_validate_non_string_key(self): + """测试验证非字符串 key""" + # 注意:在 Python 中,dict key 通常会被转换为字符串 + # 这个测试主要验证类型检查逻辑 + data = {"key1": "value1"} + credential = CustomCredential(data=data) + validated = credential.validate_data() + assert validated == data + + def test_validate_value_all_asterisks(self): + """测试验证全为星号的值""" + data = {"key1": "***"} + credential = CustomCredential(data=data) + with pytest.raises(serializers.ValidationError) as exc_info: + credential.validate_data() + assert "不应全为 * 字符" in str(exc_info.value) + + def test_display_value_masks_all_values(self): + """测试脱敏显示(所有值都脱敏)""" + data = {"key1": "value1", "key2": "value2"} + credential = CustomCredential(data=data) + display = credential.display_value() + assert display["key1"] == "*********" + assert display["key2"] == "*********" + + +class TestCredentialDispatcher: + """测试凭证分发器""" + + def test_get_bk_app_credential(self): + """测试获取蓝鲸应用凭证""" + data = {"bk_app_code": "app", "bk_app_secret": "secret"} + credential = CredentialDispatcher("BK_APP", data=data) + assert credential is not None + assert credential.validate_data() == data + + def test_get_bk_access_token_credential(self): + """测试获取蓝鲸登录态凭证""" + data = {"access_token": "token123"} + credential = CredentialDispatcher("BK_ACCESS_TOKEN", data=data) + assert credential is not None + assert credential.validate_data() == data + + def test_get_basic_auth_credential(self): + """测试获取用户名+密码凭证""" + data = {"username": "admin", "password": "secret"} + credential = CredentialDispatcher("BASIC_AUTH", data=data) + assert credential is not None + assert credential.validate_data() == data + + def test_get_custom_credential(self): + """测试获取自定义凭证""" + data = {"key1": "value1"} + credential = CredentialDispatcher("CUSTOM", data=data) + assert credential is not None + assert credential.validate_data() == data + + def test_unsupported_credential_type(self): + """测试不支持的凭证类型""" + with pytest.raises(CredentialTypeNotSupport): + CredentialDispatcher("UNKNOWN_TYPE", data={}) + + def test_lowercase_type_not_supported(self): + """测试小写凭证类型不支持(必须大写)""" + data = {"access_token": "token"} + # 小写类型应该抛出异常 + with pytest.raises(CredentialTypeNotSupport): + CredentialDispatcher("bk_access_token", data=data) diff --git a/tests/interface/credential/test_credential_model.py b/tests/interface/credential/test_credential_model.py new file mode 100644 index 0000000000..303111f842 --- /dev/null +++ b/tests/interface/credential/test_credential_model.py @@ -0,0 +1,268 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 django.db import IntegrityError + +from bkflow.space.models import ( + Credential, + CredentialScope, + CredentialScopeLevel, + CredentialType, + Space, +) + + +@pytest.mark.django_db +class TestCredentialModel: + """测试凭证模型""" + + @pytest.fixture + def test_space(self): + """创建测试空间""" + space = Space.objects.create(name="test_space", app_code="test_app", creator="test_user") + yield space + space.hard_delete() + + @pytest.fixture + def test_credential(self, test_space): + """创建测试凭证""" + credential = Credential.create_credential( + space_id=test_space.id, + name="test_credential", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app", "bk_app_secret": "secret"}, + creator="test_user", + desc="Test credential", + ) + yield credential + credential.hard_delete() + + def test_create_credential(self, test_space): + """测试创建凭证""" + credential = Credential.create_credential( + space_id=test_space.id, + name="new_credential", + type=CredentialType.BK_ACCESS_TOKEN.value, + content={"access_token": "token123"}, + creator="test_user", + ) + + assert credential.id is not None + assert credential.space_id == test_space.id + assert credential.name == "new_credential" + assert credential.type == CredentialType.BK_ACCESS_TOKEN.value + assert credential.creator == "test_user" + + # 清理 + credential.hard_delete() + + def test_create_duplicate_credential_name(self, test_space, test_credential): + """测试创建重复名称的凭证""" + from django.db import transaction + + with pytest.raises(IntegrityError): + with transaction.atomic(): + Credential.create_credential( + space_id=test_space.id, + name="test_credential", # 与 fixture 中的名称重复 + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app2", "bk_app_secret": "secret2"}, + creator="test_user", + ) + + def test_update_credential(self, test_credential): + """测试更新凭证内容""" + new_content = {"bk_app_code": "new_app", "bk_app_secret": "new_secret"} + test_credential.update_credential(new_content) + + # 重新从数据库读取 + credential = Credential.objects.get(id=test_credential.id) + assert credential.content == new_content + + def test_credential_value_property(self, test_credential): + """测试凭证 value 属性""" + value = test_credential.value + assert isinstance(value, dict) + assert "bk_app_code" in value + assert "bk_app_secret" in value + + def test_credential_display_json(self, test_credential): + """测试凭证 display_json 方法""" + display = test_credential.display_json() + assert display["id"] == test_credential.id + assert display["space_id"] == test_credential.space_id + assert display["type"] == test_credential.type + assert "content" in display + # bk_app_secret 应该被脱敏 + assert display["content"]["bk_app_secret"] == "*********" + + def test_soft_delete(self, test_credential): + """测试软删除""" + credential_id = test_credential.id + test_credential.delete() + + # 验证软删除 + assert test_credential.is_deleted is True + + # 验证在查询中被过滤 + with pytest.raises(Credential.DoesNotExist): + Credential.objects.get(id=credential_id, is_deleted=False) + + # 但仍然存在于数据库 + credential = Credential.objects.get(id=credential_id) + assert credential.is_deleted is True + + def test_hard_delete(self, test_space): + """测试硬删除""" + credential = Credential.create_credential( + space_id=test_space.id, + name="to_be_deleted", + type=CredentialType.CUSTOM.value, + content={"key": "value"}, + creator="test_user", + ) + credential_id = credential.id + + credential.hard_delete() + + # 验证已从数据库删除 + with pytest.raises(Credential.DoesNotExist): + Credential.objects.get(id=credential_id) + + +@pytest.mark.django_db +class TestCredentialScope: + """测试凭证作用域""" + + @pytest.fixture + def test_space(self): + """创建测试空间""" + space = Space.objects.create(name="test_space", app_code="test_app", creator="test_user") + yield space + space.hard_delete() + + @pytest.fixture + def test_credential(self, test_space): + """创建测试凭证""" + credential = Credential.create_credential( + space_id=test_space.id, + name="scoped_credential", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app", "bk_app_secret": "secret"}, + creator="test_user", + ) + yield credential + credential.hard_delete() + + def test_create_credential_scope(self, test_credential): + """测试创建凭证作用域""" + scope = CredentialScope.objects.create( + credential_id=test_credential.id, scope_type="project", scope_value="project_1" + ) + + assert scope.id is not None + assert scope.credential_id == test_credential.id + assert scope.scope_type == "project" + assert scope.scope_value == "project_1" + + # 清理 + scope.delete() + + def test_get_scopes(self, test_credential): + """测试获取凭证的作用域列表""" + # 创建多个作用域 + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_1") + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_2") + + scopes = test_credential.get_scopes() + assert scopes.count() == 2 + + # 清理 + scopes.delete() + + def test_has_scope(self, test_credential): + """测试检查凭证是否设置了作用域""" + # 初始状态:没有作用域 + assert test_credential.has_scope() is False + + # 添加作用域 + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_1") + + # 现在应该有作用域 + assert test_credential.has_scope() is True + + # 清理 + test_credential.get_scopes().delete() + + def test_can_use_in_scope_without_scope(self, test_credential): + """测试没有作用域限制的凭证不能被使用""" + # 凭证没有设置作用域,不允许被使用 + assert test_credential.can_use_in_scope("project", "project_1") is False + assert test_credential.can_use_in_scope(None, None) is False + + def test_can_use_in_scope_with_matching_scope(self, test_credential): + """测试匹配作用域的凭证可以使用""" + # 设置 scope_level 为 PART + test_credential.scope_level = CredentialScopeLevel.PART.value + test_credential.save() + + # 添加作用域 + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_1") + + # 匹配的作用域应该可以使用 + assert test_credential.can_use_in_scope("project", "project_1") is True + + # 不匹配的作用域不能使用 + assert test_credential.can_use_in_scope("project", "project_2") is False + assert test_credential.can_use_in_scope("template", "template_1") is False + + # 清理 + test_credential.get_scopes().delete() + + def test_can_use_in_scope_with_template_no_scope(self, test_credential): + """测试模板没有作用域时,scope_level == ALL 的凭证可以使用""" + # 设置 scope_level 为 ALL(空间内开放) + test_credential.scope_level = CredentialScopeLevel.ALL.value + test_credential.save() + + # 模板没有作用域(都为 None),scope_level == ALL 的凭证可以使用 + assert test_credential.can_use_in_scope(None, None) is True + + # 有作用域时也可以使用 + assert test_credential.can_use_in_scope("project", "project_1") is True + + def test_multiple_scopes(self, test_credential): + """测试多个作用域""" + # 设置 scope_level 为 PART + test_credential.scope_level = CredentialScopeLevel.PART.value + test_credential.save() + + # 添加多个作用域 + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_1") + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_2") + + # 两个作用域都应该可以使用 + assert test_credential.can_use_in_scope("project", "project_1") is True + assert test_credential.can_use_in_scope("project", "project_2") is True + + # 其他作用域不能使用 + assert test_credential.can_use_in_scope("project", "project_3") is False + + # 清理 + test_credential.get_scopes().delete() diff --git a/tests/interface/credential/test_credential_resolver.py b/tests/interface/credential/test_credential_resolver.py new file mode 100644 index 0000000000..fa46345405 --- /dev/null +++ b/tests/interface/credential/test_credential_resolver.py @@ -0,0 +1,184 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 bkflow.space.credential.resolver import resolve_credentials +from bkflow.space.exceptions import ( + CredentialNotFoundError, + CredentialScopeValidationError, +) +from bkflow.space.models import ( + Credential, + CredentialScope, + CredentialScopeLevel, + CredentialType, + Space, +) + + +@pytest.mark.django_db +class TestResolveCredentials: + """测试凭证解析器""" + + @pytest.fixture + def test_space(self): + """创建测试空间""" + space = Space.objects.create(name="test_space", app_code="test_app", creator="test_user") + yield space + space.hard_delete() + + @pytest.fixture + def test_credential(self, test_space): + """创建测试凭证(有作用域)""" + credential = Credential.create_credential( + space_id=test_space.id, + name="test_credential", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app", "bk_app_secret": "secret"}, + creator="test_user", + scope_level=CredentialScopeLevel.PART.value, + ) + # 添加默认作用域 + CredentialScope.objects.create(credential_id=credential.id, scope_type="test", scope_value="test_1") + yield credential + credential.hard_delete() + + @pytest.fixture + def scoped_credential(self, test_space): + """创建有作用域的凭证""" + credential = Credential.create_credential( + space_id=test_space.id, + name="scoped_credential", + type=CredentialType.BASIC_AUTH.value, + content={"username": "admin", "password": "secret"}, + creator="test_user", + scope_level=CredentialScopeLevel.PART.value, + ) + CredentialScope.objects.create(credential_id=credential.id, scope_type="project", scope_value="project_1") + yield credential + credential.hard_delete() + + def test_resolve_empty_credentials(self, test_space): + """测试解析空凭证字典""" + result = resolve_credentials({}, test_space.id, None, None) + assert result == {} + + def test_resolve_none_credentials(self, test_space): + """测试解析 None""" + result = resolve_credentials(None, test_space.id, None, None) + assert result == {} + + def test_resolve_credential_by_id(self, test_space, test_credential): + """测试通过 ID 解析凭证""" + credentials_dict = { + "${token1}": { + "desc": "测试凭证", + "index": 1, + "key": "${token1}", + "name": "凭证1", + "show_type": "show", + "value": str(test_credential.id), + "version": "legacy", + } + } + + result = resolve_credentials(credentials_dict, test_space.id, "test", "test_1") # 匹配fixture中的作用域 + + assert "${token1}" in result + assert result["${token1}"]["credential_id"] == test_credential.id + assert result["${token1}"]["credential_name"] == "test_credential" + assert result["${token1}"]["credential_type"] == CredentialType.BK_APP.value + # value 应该是解密后的实际内容 + assert isinstance(result["${token1}"]["value"], dict) + + def test_resolve_credential_by_int_id(self, test_space, test_credential): + """测试通过整数 ID 解析凭证""" + credentials_dict = {"${token1}": {"value": test_credential.id, "name": "凭证1"}} # 整数类型 + + result = resolve_credentials(credentials_dict, test_space.id, "test", "test_1") # 匹配fixture中的作用域 + + assert "${token1}" in result + assert result["${token1}"]["credential_id"] == test_credential.id + + def test_resolve_non_existent_credential(self, test_space): + """测试解析不存在的凭证""" + credentials_dict = {"${token1}": {"value": "99999", "name": "不存在的凭证"}} # 不存在的 ID + + with pytest.raises(CredentialNotFoundError) as exc_info: + resolve_credentials(credentials_dict, test_space.id, None, None) + assert "不存在" in str(exc_info.value) + + def test_resolve_credential_with_scope_validation_pass(self, test_space, scoped_credential): + """测试解析凭证时作用域验证通过""" + credentials_dict = {"${token1}": {"value": str(scoped_credential.id), "name": "作用域凭证"}} + + # 匹配的作用域应该通过 + result = resolve_credentials(credentials_dict, test_space.id, "project", "project_1") + + assert "${token1}" in result + assert result["${token1}"]["credential_id"] == scoped_credential.id + + def test_resolve_credential_with_scope_validation_fail(self, test_space, scoped_credential): + """测试解析凭证时作用域验证失败""" + credentials_dict = {"${token1}": {"value": str(scoped_credential.id), "name": "作用域凭证"}} + + # 不匹配的作用域应该失败 + with pytest.raises(CredentialScopeValidationError): + resolve_credentials(credentials_dict, test_space.id, "project", "project_2") # 不匹配 + + def test_resolve_direct_value(self, test_space): + """测试解析直接提供的值(非 ID)""" + credentials_dict = {"${token1}": {"value": "direct_token_value", "name": "直接值"}} # 不是数字,直接使用 + + result = resolve_credentials(credentials_dict, test_space.id, None, None) + + assert "${token1}" in result + assert result["${token1}"]["value"] == "direct_token_value" + assert "credential_id" not in result["${token1}"] + + def test_resolve_multiple_credentials(self, test_space, test_credential, scoped_credential): + """测试解析多个凭证""" + # 添加匹配的作用域到 scoped_credential + CredentialScope.objects.create(credential_id=scoped_credential.id, scope_type="test", scope_value="test_1") + + credentials_dict = { + "${token1}": {"value": str(test_credential.id), "name": "凭证1"}, + "${token2}": {"value": str(scoped_credential.id), "name": "凭证2"}, + "${token3}": {"value": "direct_value", "name": "直接值"}, + } + + result = resolve_credentials(credentials_dict, test_space.id, "test", "test_1") # 匹配作用域 + + assert len(result) == 3 + assert "${token1}" in result + assert "${token2}" in result + assert "${token3}" in result + assert result["${token1}"]["credential_id"] == test_credential.id + assert result["${token2}"]["credential_id"] == scoped_credential.id + assert result["${token3}"]["value"] == "direct_value" + + def test_resolve_credential_with_empty_value(self, test_space): + """测试解析空值的凭证""" + credentials_dict = {"${token1}": {"value": "", "name": "空值凭证"}} # 空值 + + result = resolve_credentials(credentials_dict, test_space.id, None, None) + + # 空值应该被跳过或保持原样 + assert "${token1}" in result + assert result["${token1}"]["value"] == "" diff --git a/tests/interface/credential/test_credential_scope_validator.py b/tests/interface/credential/test_credential_scope_validator.py new file mode 100644 index 0000000000..dc895017a5 --- /dev/null +++ b/tests/interface/credential/test_credential_scope_validator.py @@ -0,0 +1,192 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 bkflow.space.credential.scope_validator import ( + filter_credentials_by_scope, + validate_credential_scope, +) +from bkflow.space.exceptions import CredentialScopeValidationError +from bkflow.space.models import ( + Credential, + CredentialScope, + CredentialScopeLevel, + CredentialType, + Space, +) + + +@pytest.mark.django_db +class TestValidateCredentialScope: + """测试凭证作用域验证""" + + @pytest.fixture + def test_space(self): + """创建测试空间""" + space = Space.objects.create(name="test_space", app_code="test_app", creator="test_user") + yield space + space.hard_delete() + + @pytest.fixture + def credential_with_scope(self, test_space): + """创建有作用域限制的凭证""" + credential = Credential.create_credential( + space_id=test_space.id, + name="scoped_credential", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app", "bk_app_secret": "secret"}, + creator="test_user", + scope_level=CredentialScopeLevel.PART.value, + ) + CredentialScope.objects.create(credential_id=credential.id, scope_type="project", scope_value="project_1") + yield credential + credential.hard_delete() + + @pytest.fixture + def credential_without_scope(self, test_space): + """创建没有作用域限制的凭证(scope_level == ALL)""" + credential = Credential.create_credential( + space_id=test_space.id, + name="no_scope_credential", + type=CredentialType.BASIC_AUTH.value, + content={"username": "admin", "password": "secret"}, + creator="test_user", + scope_level=CredentialScopeLevel.ALL.value, + ) + yield credential + credential.hard_delete() + + def test_validate_credential_with_matching_scope(self, credential_with_scope): + """测试验证匹配作用域的凭证""" + # 应该通过验证 + result = validate_credential_scope(credential_with_scope, "project", "project_1") + assert result is True + + def test_validate_credential_with_non_matching_scope(self, credential_with_scope): + """测试验证不匹配作用域的凭证""" + # 应该抛出异常 + with pytest.raises(CredentialScopeValidationError) as exc_info: + validate_credential_scope(credential_with_scope, "project", "project_2") + assert "不能在作用域" in str(exc_info.value) + + def test_validate_credential_without_scope_fails(self, credential_without_scope): + """测试验证 scope_level == ALL 的凭证(应该可以使用)""" + # scope_level == ALL 的凭证可以在任何地方使用 + result = validate_credential_scope(credential_without_scope, "project", "project_1") + assert result is True + + def test_validate_credential_with_scope_on_template_without_scope(self, credential_with_scope): + """测试在没有作用域的模板中使用 scope_level == PART 的凭证(应该失败)""" + # 模板没有作用域,scope_level == PART 的凭证不能使用 + with pytest.raises(CredentialScopeValidationError): + validate_credential_scope(credential_with_scope, None, None) + + +@pytest.mark.django_db +class TestFilterCredentialsByScope: + """测试根据作用域过滤凭证""" + + @pytest.fixture + def test_space(self): + """创建测试空间""" + space = Space.objects.create(name="test_space", app_code="test_app", creator="test_user") + yield space + space.hard_delete() + + @pytest.fixture + def setup_credentials(self, test_space): + """创建测试凭证""" + # 1. scope_level == ALL 的凭证(空间内开放,可以在任何地方使用) + cred1 = Credential.create_credential( + space_id=test_space.id, + name="no_scope", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app1", "bk_app_secret": "secret1"}, + creator="test_user", + scope_level=CredentialScopeLevel.ALL.value, + ) + + # 2. scope_level == PART 且有匹配作用域的凭证 + cred2 = Credential.create_credential( + space_id=test_space.id, + name="matching_scope", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app2", "bk_app_secret": "secret2"}, + creator="test_user", + scope_level=CredentialScopeLevel.PART.value, + ) + CredentialScope.objects.create(credential_id=cred2.id, scope_type="project", scope_value="project_1") + + # 3. scope_level == PART 且有不匹配作用域的凭证 + cred3 = Credential.create_credential( + space_id=test_space.id, + name="non_matching_scope", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app3", "bk_app_secret": "secret3"}, + creator="test_user", + scope_level=CredentialScopeLevel.PART.value, + ) + CredentialScope.objects.create(credential_id=cred3.id, scope_type="project", scope_value="project_2") + + yield {"no_scope": cred1, "matching": cred2, "non_matching": cred3} + + # 清理 + cred1.hard_delete() + cred2.hard_delete() + cred3.hard_delete() + + def test_filter_with_no_template_scope(self, test_space, setup_credentials): + """测试模板没有作用域时的过滤""" + queryset = Credential.objects.filter(space_id=test_space.id, is_deleted=False) + + # 模板没有作用域,只应该返回 scope_level == ALL 的凭证 + filtered = filter_credentials_by_scope(queryset, None, None) + assert filtered.count() == 1 + assert filtered.first().name == "no_scope" + + def test_filter_with_matching_scope(self, test_space, setup_credentials): + """测试匹配作用域的过滤""" + queryset = Credential.objects.filter(space_id=test_space.id, is_deleted=False) + + # 应该返回:scope_level == ALL 的凭证 + scope_level == PART 且作用域匹配的凭证 + filtered = filter_credentials_by_scope(queryset, "project", "project_1") + + # 应该只有 2 个凭证:no_scope (ALL) 和 matching_scope (PART 且匹配) + assert filtered.count() == 2 + names = [c.name for c in filtered] + assert "no_scope" in names + assert "matching_scope" in names + assert "non_matching_scope" not in names + + def test_filter_with_non_existing_scope(self, test_space, setup_credentials): + """测试不存在的作用域过滤""" + queryset = Credential.objects.filter(space_id=test_space.id, is_deleted=False) + + # 只应该返回 scope_level == ALL 的凭证(空间内开放,可以在任何地方使用) + filtered = filter_credentials_by_scope(queryset, "project", "project_999") + + assert filtered.count() == 1 + assert filtered.first().name == "no_scope" + + def test_filter_empty_queryset(self, test_space): + """测试空查询集的过滤""" + queryset = Credential.objects.filter(space_id=99999) # 不存在的 space_id + + filtered = filter_credentials_by_scope(queryset, "project", "project_1") + assert filtered.count() == 0 diff --git a/tests/interface/credential/test_secret_json_field.py b/tests/interface/credential/test_secret_json_field.py new file mode 100644 index 0000000000..a58f53e9c8 --- /dev/null +++ b/tests/interface/credential/test_secret_json_field.py @@ -0,0 +1,221 @@ +""" +TencentBlueKing is pleased to support the open source community by making +蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. +Copyright (C) 2024 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 django.conf import settings +from django.db import connection + +from bkflow.space.models import Credential, CredentialType, Space +from bkflow.utils.crypt import BaseCrypt + + +@pytest.mark.django_db +class TestSecretSingleJsonField: + """测试 SecretSingleJsonField 加密字段""" + + @pytest.fixture + def test_space(self): + """创建测试空间""" + space = Space.objects.create(name="test_space", app_code="test_app", creator="test_user") + yield space + space.hard_delete() + + @pytest.fixture + def crypt(self): + """创建加密器""" + return BaseCrypt(instance_key=settings.PRIVATE_SECRET) + + def test_encrypt_on_save(self, test_space, crypt): + """测试保存时自动加密""" + # 创建凭证 + credential = Credential.create_credential( + space_id=test_space.id, + name="test_encrypt", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app", "bk_app_secret": "secret123"}, + creator="test_user", + ) + + # 从数据库直接读取原始值 + with connection.cursor() as cursor: + cursor.execute("SELECT content FROM space_credential WHERE id=%s", [credential.id]) + raw_content = cursor.fetchone()[0] + + # 数据库返回的是 JSON 字符串,需要解析 + import json + + if isinstance(raw_content, str): + raw_content = json.loads(raw_content) + + # 验证数据库中的值是加密的 + assert isinstance(raw_content, dict) + assert "bk_app_code" in raw_content + assert "bk_app_secret" in raw_content + + # 验证值已加密(不等于原始值) + assert raw_content["bk_app_code"] != "app" + assert raw_content["bk_app_secret"] != "secret123" + + # 验证可以解密 + decrypted_code = crypt.decrypt(raw_content["bk_app_code"]) + decrypted_secret = crypt.decrypt(raw_content["bk_app_secret"]) + assert decrypted_code == "app" + assert decrypted_secret == "secret123" + + # 清理 + credential.hard_delete() + + def test_decrypt_on_read(self, test_space): + """测试读取时自动解密""" + # 创建凭证 + original_content = {"username": "admin", "password": "secret456"} + credential = Credential.create_credential( + space_id=test_space.id, + name="test_decrypt", + type=CredentialType.BASIC_AUTH.value, + content=original_content, + creator="test_user", + ) + + # 通过 ORM 读取(应该自动解密) + credential = Credential.objects.get(id=credential.id) + assert credential.content == original_content + assert credential.content["username"] == "admin" + assert credential.content["password"] == "secret456" + + # 清理 + credential.hard_delete() + + def test_update_encrypted_field(self, test_space): + """测试更新加密字段""" + # 创建凭证 + credential = Credential.create_credential( + space_id=test_space.id, + name="test_update", + type=CredentialType.CUSTOM.value, + content={"key1": "value1"}, + creator="test_user", + ) + + # 更新内容 + new_content = {"key1": "new_value1", "key2": "value2"} + credential.update_credential(new_content) + + # 重新读取验证 + credential = Credential.objects.get(id=credential.id) + assert credential.content == new_content + assert credential.content["key1"] == "new_value1" + assert credential.content["key2"] == "value2" + + # 清理 + credential.hard_delete() + + def test_none_value_not_encrypted(self, test_space): + """测试 None 值不加密""" + credential = Credential.create_credential( + space_id=test_space.id, + name="test_none", + type=CredentialType.CUSTOM.value, + content={"key1": "value1"}, + creator="test_user", + ) + + # 验证可以正常保存和读取 + credential = Credential.objects.get(id=credential.id) + assert credential.content["key1"] == "value1" + + # 清理 + credential.hard_delete() + + def test_empty_dict(self, test_space): + """测试空字典""" + # 创建时传入空字典应该会报错(CustomCredential 不允许空字典) + with pytest.raises(Exception): # ValidationError + Credential.create_credential( + space_id=test_space.id, + name="test_empty", + type=CredentialType.CUSTOM.value, + content={}, + creator="test_user", + ) + + def test_single_level_json_only(self, test_space): + """测试只支持单层 JSON""" + # 嵌套字典应该失败(在 CustomCredential 验证时就会失败) + with pytest.raises(Exception): # ValidationError + Credential.create_credential( + space_id=test_space.id, + name="test_nested", + type=CredentialType.CUSTOM.value, + content={"key1": {"nested": "value"}}, + creator="test_user", + ) + + def test_multiple_credentials_encryption(self, test_space): + """测试多个凭证的加密独立性""" + # 创建多个凭证 + cred1 = Credential.create_credential( + space_id=test_space.id, + name="cred1", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app1", "bk_app_secret": "secret1"}, + creator="test_user", + ) + + cred2 = Credential.create_credential( + space_id=test_space.id, + name="cred2", + type=CredentialType.BK_APP.value, + content={"bk_app_code": "app2", "bk_app_secret": "secret2"}, + creator="test_user", + ) + + # 验证两个凭证的内容独立 + cred1_reloaded = Credential.objects.get(id=cred1.id) + cred2_reloaded = Credential.objects.get(id=cred2.id) + + assert cred1_reloaded.content["bk_app_code"] == "app1" + assert cred1_reloaded.content["bk_app_secret"] == "secret1" + assert cred2_reloaded.content["bk_app_code"] == "app2" + assert cred2_reloaded.content["bk_app_secret"] == "secret2" + + # 清理 + cred1.hard_delete() + cred2.hard_delete() + + def test_special_characters_encryption(self, test_space): + """测试特殊字符的加密""" + special_content = {"key1": "value!@#$%^&*()", "key2": "中文测试", "key3": "emoji🎉"} + + credential = Credential.create_credential( + space_id=test_space.id, + name="test_special", + type=CredentialType.CUSTOM.value, + content=special_content, + creator="test_user", + ) + + # 验证特殊字符正确加密和解密 + credential = Credential.objects.get(id=credential.id) + assert credential.content == special_content + assert credential.content["key1"] == "value!@#$%^&*()" + assert credential.content["key2"] == "中文测试" + assert credential.content["key3"] == "emoji🎉" + + # 清理 + credential.hard_delete() diff --git a/urls.py b/urls.py index 65b48c762b..66dd276771 100644 --- a/urls.py +++ b/urls.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ TencentBlueKing is pleased to support the open source community by making 蓝鲸流程引擎服务 (BlueKing Flow Engine Service) available. @@ -51,9 +50,9 @@ schema_view = get_schema_view( openapi.Info( - title="BK-SOPS API", + title="BK-FLOW API", default_version="v1", - description="标准运维API文档,接口返回中默认带有result、data、message等字段,如果响应体中没有体现,则说明响应体只展示了其中data字段的内容。", + description="BKFlow API文档,接口返回中默认带有result、data、message等字段,如果响应体中没有体现,则说明响应体只展示了其中data字段的内容。", ), public=True, permission_classes=(permissions.IsAdminUser,),