From 52ebd1a71acd976e2d2f6d202aca7753da80b221 Mon Sep 17 00:00:00 2001 From: dengyh Date: Wed, 22 Oct 2025 16:43:31 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=A2=9E=E5=BC=BA=20=20--story=3D125449007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/apigw/serializers/credential.py | 94 ++++++- bkflow/apigw/serializers/task.py | 3 + bkflow/apigw/urls.py | 2 + bkflow/apigw/views/create_credential.py | 35 ++- bkflow/apigw/views/create_mock_task.py | 8 + bkflow/apigw/views/create_task.py | 8 + .../views/create_task_without_template.py | 13 + bkflow/apigw/views/update_credential.py | 89 ++++++ bkflow/exceptions.py | 10 +- .../static/variables/credential.js | 125 +++++++++ .../variables/collections/credential.py | 44 +++ bkflow/space/admin.py | 9 +- bkflow/space/credential/__init__.py | 15 +- bkflow/space/credential/basic_auth.py | 70 +++++ bkflow/space/credential/bk_access_token.py | 69 +++++ .../space/credential/{bkapp.py => bk_app.py} | 26 +- bkflow/space/credential/custom.py | 70 +++++ bkflow/space/credential/dispatcher.py | 13 +- bkflow/space/credential/resolver.py | 79 ++++++ bkflow/space/credential/scope_validator.py | 82 ++++++ bkflow/space/exceptions.py | 12 +- .../migrations/0008_auto_20251014_1511.py | 83 ++++++ .../0009_encrypt_credential_content.py | 200 ++++++++++++++ bkflow/space/models.py | 100 ++++++- bkflow/space/serializers.py | 24 +- bkflow/space/views.py | 130 +++++++-- bkflow/task/serializers.py | 8 + bkflow/utils/crypt.py | 63 +++++ bkflow/utils/models.py | 131 ++++++++- bkflow/utils/serializer.py | 169 ++++++++++++ config/default.py | 3 + env.py | 4 + frontend/src/config/i18n/cn.js | 4 + frontend/src/config/i18n/en.js | 4 + tests/interface.env | 1 + tests/interface/credential/__init__.py | 18 ++ tests/interface/credential/test_credential.py | 218 +++++++++++++++ .../credential/test_credential_model.py | 253 ++++++++++++++++++ .../credential/test_credential_resolver.py | 176 ++++++++++++ .../test_credential_scope_validator.py | 180 +++++++++++++ .../credential/test_secret_json_field.py | 221 +++++++++++++++ 41 files changed, 2790 insertions(+), 76 deletions(-) create mode 100644 bkflow/apigw/views/update_credential.py create mode 100644 bkflow/pipeline_plugins/static/variables/credential.js create mode 100644 bkflow/pipeline_plugins/variables/collections/credential.py create mode 100644 bkflow/space/credential/basic_auth.py create mode 100644 bkflow/space/credential/bk_access_token.py rename bkflow/space/credential/{bkapp.py => bk_app.py} (76%) create mode 100644 bkflow/space/credential/custom.py create mode 100644 bkflow/space/credential/resolver.py create mode 100644 bkflow/space/credential/scope_validator.py create mode 100644 bkflow/space/migrations/0008_auto_20251014_1511.py create mode 100644 bkflow/space/migrations/0009_encrypt_credential_content.py create mode 100644 bkflow/utils/crypt.py create mode 100644 bkflow/utils/serializer.py create mode 100644 tests/interface/credential/__init__.py create mode 100644 tests/interface/credential/test_credential.py create mode 100644 tests/interface/credential/test_credential_model.py create mode 100644 tests/interface/credential/test_credential_resolver.py create mode 100644 tests/interface/credential/test_credential_scope_validator.py create mode 100644 tests/interface/credential/test_secret_json_field.py diff --git a/bkflow/apigw/serializers/credential.py b/bkflow/apigw/serializers/credential.py index a826b22a30..d40d6029d0 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,99 @@ 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, CredentialScope + + +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 CredentialScopeSerializer(serializers.ModelSerializer): + """凭证作用域序列化器""" + + class Meta: + model = CredentialScope + fields = ["scope_type", "scope_value"] + + +class CredentialScopesChangeSerializer(serializers.Serializer): + """凭证作用域变更序列化器""" + + scopes = serializers.ListField( + child=CredentialScopeSerializer(), help_text=_("凭证作用域列表"), required=False, default=list + ) + unlimited = serializers.BooleanField(help_text=_("是否无限制"), required=False, default=False) + + def validate(self, attrs): + if attrs.get("unlimited"): + if attrs.get("scopes"): + raise serializers.ValidationError(_("无限制时不能设置作用域")) + + if not attrs.get("unlimited") and not attrs.get("scopes"): + raise serializers.ValidationError(_("作用域不能为空")) + return attrs 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) + scopes = serializers.ListField( + child=CredentialScopeSerializer(), help_text=_("凭证作用域列表"), required=False, default=list + ) - def validate_content(self, value): - content_ser = BkAppCredential.BkAppSerializer(data=value) - content_ser.is_valid(raise_exception=True) - return value + def validate(self, attrs): + # 动态验证content根据type + credential_type = attrs.get("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)}) + + 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) + scopes = serializers.ListField(child=CredentialScopeSerializer(), help_text=_("凭证作用域列表"), required=False) + + def validate(self, attrs): + # 如果提供了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..13ca044279 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 @login_exempt @@ -36,9 +36,36 @@ @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", []) + + # 创建凭证和作用域 + with transaction.atomic(): + # 序列化器已经检查过是否存在了 + credential = Credential.create_credential(**credential_data, space_id=space_id, creator=request.user.username) + + # 创建凭证作用域 + 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) + 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..68cf39fc45 --- /dev/null +++ b/bkflow/space/credential/scope_validator.py @@ -0,0 +1,82 @@ +""" +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 + + +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: 过滤后的凭证查询集 + """ + from bkflow.space.models import CredentialScope + + # 获取所有凭证ID + all_credential_ids = set(credentials_queryset.values_list("id", flat=True)) + + # 获取有作用域限制的凭证ID + credentials_with_scope = set( + CredentialScope.objects.filter(credential_id__in=all_credential_ids).values_list("credential_id", flat=True) + ) + + # 没有作用域限制的凭证ID(可以在任何地方使用) + credentials_without_scope = all_credential_ids - credentials_with_scope + + # 如果模板没有作用域,返回所有凭证 + if not scope_type and not scope_value: + return credentials_queryset + + # 查找匹配当前作用域的凭证ID + matching_credential_ids = set( + CredentialScope.objects.filter( + credential_id__in=credentials_with_scope, scope_type=scope_type, scope_value=scope_value + ).values_list("credential_id", flat=True) + ) + + # 返回:没有作用域限制的凭证 + 匹配当前作用域的凭证 + available_credential_ids = credentials_without_scope | 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/models.py b/bkflow/space/models.py index b35b626f09..7cd8184282 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,27 @@ 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 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, _("自定义")), + ] 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) + content = SecretSingleJsonField(_("凭证内容"), null=True, blank=True, default=dict) def display_json(self): credential = CredentialDispatcher(self.type, data=self.content) @@ -284,6 +294,15 @@ def value(self): def create_credential(cls, space_id, name, type, content, creator, desc=None): """ 创建一个凭证 + + :param space_id: 空间ID + :param name: 凭证名称 + :param type: 凭证类型 + :param content: 凭证内容 + :param creator: 创建者 + :param desc: 凭证描述(可选) + + :return: 创建的凭证实例 """ if not Space.exists(space_id): raise SpaceNotExists("space_id: {}".format(space_id)) @@ -302,12 +321,83 @@ def create_credential(cls, space_id, name, type, content, creator, desc=None): 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): + """ + 检查凭证是否可以在指定作用域中使用 + 如果凭证没有设置作用域,则可以在任何作用域使用 + 如果模板没有作用域(scope_type和scope_value都为空),则可以使用任何凭证 + 否则,凭证的作用域必须匹配模板的作用域 + + :param self: 凭证实例 + :param template_scope_type: 作用域类型 + :param template_scope_value: 作用域值 + :return: 如果可以使用返回 True,否则返回 False + """ + if not self.has_scope(): + # 凭证没有设置作用域,不允许被使用 + return False + + if not template_scope_type and not template_scope_value: + # 模板没有作用域,可以使用任何凭证 + return True + + # 检查是否有匹配的作用域 + return ( + self.get_scopes() + .filter( + scope_type=template_scope_type, + scope_value=template_scope_value, + ) + .exists() + ) + 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..5a20f12709 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 Space, SpaceConfig logger = logging.getLogger(__name__) @@ -74,23 +72,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..d44fe53dd5 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,9 @@ from bkflow.apigw.serializers.credential import ( CreateCredentialSerializer, + CredentialScopesChangeSerializer, + CredentialScopeSerializer, + CredentialSerializer, UpdateCredentialSerializer, ) from bkflow.apigw.serializers.space import CreateSpaceSerializer @@ -46,9 +49,11 @@ 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, CredentialType, Space, SpaceConfig, @@ -61,7 +66,6 @@ ) from bkflow.space.serializers import ( CredentialBaseQuerySerializer, - CredentialSerializer, SpaceConfigBaseQuerySerializer, SpaceConfigBatchApplySerializer, SpaceConfigSerializer, @@ -70,6 +74,7 @@ from bkflow.utils.api_client import ApiGwClient, HttpRequestResult from bkflow.utils.mixins import BKFLOWDefaultPagination from bkflow.utils.permissions import AdminPermission, AppInternalPermission +from bkflow.utils.serializer import params_valid from bkflow.utils.views import AdminModelViewSet, SimpleGenericViewSet logger = logging.getLogger("root") @@ -89,6 +94,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") @@ -352,15 +371,31 @@ 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"), + ) + + # 创建凭证作用域 + scopes = credential_data.get("scopes", []) + 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) + except DatabaseError as e: err_msg = f"创建凭证失败 {str(e)}" logger.error(err_msg) @@ -379,13 +414,33 @@ 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) + + # 更新凭证作用域 + if scopes_data is not None: + # 删除旧的作用域 + CredentialScope.objects.filter(credential_id=instance.id).delete() + # 创建新的作用域 + if scopes_data: + scope_objects = [ + CredentialScope( + credential_id=instance.id, + scope_type=scope.get("scope_type"), + scope_value=scope.get("scope_value"), + ) + for scope in scopes_data + ] + CredentialScope.objects.bulk_create(scope_objects) + except DatabaseError as e: err_msg = f"更新凭证失败 {str(e)}" logger.error(err_msg) @@ -394,6 +449,49 @@ def partial_update(self, request, *args, **kwargs): response_serializer = CredentialSerializer(instance) return Response(response_serializer.data, status=status.HTTP_200_OK) + @action(detail=True, methods=["put", "patch"]) + @params_valid(CredentialScopesChangeSerializer) + def update_scopes(self, request, pk=None, params=None): + """更新凭证作用域""" + try: + instance = self.get_object() + except Credential.DoesNotExist as e: + err_msg = f"更新凭证不存在 {str(e)}" + logger.error(err_msg) + return Response(err_msg, status=404) + + # 验证scopes数据 + params = params or {} + if params.get("unlimited"): + scopes_data = [{"scope_type": None, "scope_value": None}] + else: + scopes_data = params.get("scopes", []) + + try: + with transaction.atomic(): + # 删除旧的作用域 + CredentialScope.objects.filter(credential_id=instance.id).delete() + # 创建新的作用域 + scope_objects = [ + CredentialScope( + credential_id=instance.id, + scope_type=scope.get("scope_type"), + scope_value=scope.get("scope_value"), + ) + for scope in scopes_data + ] + CredentialScope.objects.bulk_create(scope_objects) + except DatabaseError as e: + err_msg = f"更新凭证作用域失败 {str(e)}" + logger.error(err_msg) + return Response(exception=True, data={"detail": err_msg}) + + # 返回更新后的凭证信息 + credential_scopes = [] + for scope_object in scope_objects: + credential_scopes.append(CredentialScopeSerializer(scope_object).data) + return Response(credential_scopes, status=status.HTTP_200_OK) + def destroy(self, request, *args, **kwargs): try: instance = self.get_object() 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/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/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 1578a507a9..0eb70a6e99 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -926,6 +926,10 @@ const cn = { 凭证管理: '凭证管理', 内容: '内容', '凭证删除后不可恢复,确认删除?': '凭证删除后不可恢复,确认删除?', + 蓝鲸应用凭证: '蓝鲸应用凭证', + '蓝鲸 Access Token 凭证': '蓝鲸 Access Token 凭证', + 'Basic Auth': 'Basic Auth', + 自定义: '自定义', 值类型: '值类型', 表单模式: '表单模式', json模式: 'json模式', diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index 9ba229c05f..5a4a7b9d4d 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', 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..735070d7dc --- /dev/null +++ b/tests/interface/credential/test_credential_model.py @@ -0,0 +1,253 @@ +""" +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, 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): + """测试匹配作用域的凭证可以使用""" + # 添加作用域 + 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): + """测试模板没有作用域时,有作用域的凭证也可以使用""" + # 添加作用域 + CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_1") + + # 模板没有作用域(都为 None),有作用域的凭证也可以使用 + assert test_credential.can_use_in_scope(None, None) is True + + # 清理 + test_credential.get_scopes().delete() + + def test_multiple_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") + + # 两个作用域都应该可以使用 + 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..50be60bbeb --- /dev/null +++ b/tests/interface/credential/test_credential_resolver.py @@ -0,0 +1,176 @@ +""" +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, 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", + ) + # 添加默认作用域 + 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", + ) + 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..4bc1951246 --- /dev/null +++ b/tests/interface/credential/test_credential_scope_validator.py @@ -0,0 +1,180 @@ +""" +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, 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", + ) + 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): + """创建没有作用域限制的凭证""" + 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", + ) + 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): + """测试验证没有作用域的凭证(应该失败)""" + # 凭证没有作用域,不允许使用 + with pytest.raises(CredentialScopeValidationError): + validate_credential_scope(credential_without_scope, "project", "project_1") + + def test_validate_credential_with_scope_on_template_without_scope(self, credential_with_scope): + """测试在没有作用域的模板中使用有作用域的凭证""" + # 模板没有作用域,有作用域的凭证可以使用 + result = validate_credential_scope(credential_with_scope, None, None) + assert result is True + + +@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. 没有作用域的凭证 + 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", + ) + + # 2. 有匹配作用域的凭证 + 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", + ) + CredentialScope.objects.create(credential_id=cred2.id, scope_type="project", scope_value="project_1") + + # 3. 有不匹配作用域的凭证 + 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", + ) + 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) + + # 模板没有作用域,应该返回所有凭证 + filtered = filter_credentials_by_scope(queryset, None, None) + assert filtered.count() == 3 + + def test_filter_with_matching_scope(self, test_space, setup_credentials): + """测试匹配作用域的过滤""" + queryset = Credential.objects.filter(space_id=test_space.id, is_deleted=False) + + # 应该返回:没有作用域的 + 匹配作用域的 + filtered = filter_credentials_by_scope(queryset, "project", "project_1") + + # 应该只有 2 个凭证:no_scope 和 matching + 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) + + # 只应该返回没有作用域的凭证 + 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() From c22ef0d05b5decc3576935a8c4ee31a91fe4fd0a Mon Sep 17 00:00:00 2001 From: dengyh Date: Fri, 24 Oct 2025 11:54:21 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E4=BD=9C=E7=94=A8=E5=9F=9F=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20--story=3D125449007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/credential/scope_validator.py | 7 ++--- bkflow/space/views.py | 35 ++++++++++++++++++++++ bkflow/template/views/template.py | 29 +++++++++++++++++- urls.py | 5 ++-- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/bkflow/space/credential/scope_validator.py b/bkflow/space/credential/scope_validator.py index 68cf39fc45..7f869a4cef 100644 --- a/bkflow/space/credential/scope_validator.py +++ b/bkflow/space/credential/scope_validator.py @@ -19,6 +19,7 @@ from django.utils.translation import ugettext_lazy as _ from bkflow.space.exceptions import CredentialScopeValidationError +from bkflow.space.models import CredentialScope def validate_credential_scope(credential, template_scope_type, template_scope_value): @@ -52,8 +53,6 @@ def filter_credentials_by_scope(credentials_queryset, scope_type, scope_value): :param scope_value: 作用域值 :return: 过滤后的凭证查询集 """ - from bkflow.space.models import CredentialScope - # 获取所有凭证ID all_credential_ids = set(credentials_queryset.values_list("id", flat=True)) @@ -65,9 +64,9 @@ def filter_credentials_by_scope(credentials_queryset, scope_type, scope_value): # 没有作用域限制的凭证ID(可以在任何地方使用) credentials_without_scope = all_credential_ids - credentials_with_scope - # 如果模板没有作用域,返回所有凭证 + # 如果模板没有作用域,只返回没有设置作用域的凭证 if not scope_type and not scope_value: - return credentials_queryset + return credentials_queryset.filter(id__in=credentials_without_scope) # 查找匹配当前作用域的凭证ID matching_credential_ids = set( diff --git a/bkflow/space/views.py b/bkflow/space/views.py index d44fe53dd5..0210a3c6ba 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -449,6 +449,41 @@ def partial_update(self, request, *args, **kwargs): response_serializer = CredentialSerializer(instance) return Response(response_serializer.data, status=status.HTTP_200_OK) + @swagger_auto_schema( + method="get", + operation_summary="获取凭证作用域", + ) + @action(detail=True, methods=["get"]) + def list_scopes(self, request, pk=None, params=None): + """获取凭证的作用域列表""" + try: + credential = self.get_object() + except Credential.DoesNotExist as e: + err_msg = f"凭证不存在 {str(e)}" + logger.error(err_msg) + return Response({"error": err_msg}, status=status.HTTP_404_NOT_FOUND) + + # 获取凭证的所有作用域 + scopes = CredentialScope.objects.filter(credential_id=credential.id) + serializer = CredentialScopeSerializer(scopes, many=True) + + # 判断是否为无限制凭证(没有设置任何作用域) + is_unlimited = not scopes.exists() + + return Response( + { + "credential_id": credential.id, + "credential_name": credential.name, + "unlimited": is_unlimited, + "scopes": serializer.data, + } + ) + + @swagger_auto_schema( + methods=["put", "patch"], + operation_summary="更新凭证作用域", + request_body=CredentialScopesChangeSerializer, + ) @action(detail=True, methods=["put", "patch"]) @params_valid(CredentialScopesChangeSerializer) def update_scopes(self, request, pk=None, params=None): 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/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,), From 323ebd292d85ac6a67ac4f0e0f3fc1f8bcbbba78 Mon Sep 17 00:00:00 2001 From: dengyh Date: Fri, 24 Oct 2025 12:32:27 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E4=BD=9C=E7=94=A8=E5=9F=9F=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20--story=3D125449007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 0210a3c6ba..a81f5a9e74 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -468,7 +468,10 @@ def list_scopes(self, request, pk=None, params=None): serializer = CredentialScopeSerializer(scopes, many=True) # 判断是否为无限制凭证(没有设置任何作用域) - is_unlimited = not scopes.exists() + if scopes.count() == 1 and scopes.first().scope_type is None and scopes.first().scope_value is None: + is_unlimited = True + else: + is_unlimited = False return Response( { From 0ab0e1c432cf0dbf73c53827903bce1bf574281e Mon Sep 17 00:00:00 2001 From: dengyh Date: Mon, 27 Oct 2025 14:48:56 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=8E=A5=E5=8F=A3=E5=A2=9E=E5=8A=A0=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E5=8F=82=E6=95=B0=20--story=3D125449007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/space/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index a81f5a9e74..90c143b69f 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -72,7 +72,7 @@ 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.serializer import params_valid from bkflow.utils.views import AdminModelViewSet, SimpleGenericViewSet @@ -345,6 +345,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) From cfc6affae8ace4c1fb14cd21db4bf21d2c5aab07 Mon Sep 17 00:00:00 2001 From: lhzzforever Date: Tue, 21 Oct 2025 19:34:42 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feature(credential):=20=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E8=8F=9C=E5=8D=95=E9=A1=B9=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature(credential): 编辑凭证作用域功能模块 feature(credential): 凭证管理模块联调后端接口 feature(credential): 自定义凭证内容增加key重复校验 feature(credential): 增加获取凭证作用域详情接口 feature(credential): 去掉列表空值筛选 feature(credential): 优化查看内容详情字段 feature(credential): 优化凭证指引图体积大小 feature(credential): 提交凭证获取指引代码 (cherry picked from commit 2512579ebcfa6e904da339666af4cb805a2f8018) --- frontend/src/assets/images/apigw.png | Bin 0 -> 72224 bytes .../src/assets/images/apigw_access_token.png | Bin 0 -> 77836 bytes .../src/components/common/FullCodeEditor.vue | 8 +- frontend/src/config/i18n/cn.js | 26 + frontend/src/config/i18n/en.js | 26 + frontend/src/constants/index.js | 22 + frontend/src/scss/mixins/credentialScope.scss | 64 ++ .../src/store/modules/credentialConfig.js | 8 + .../Space/Credential/CredentialDialog.vue | 163 ------ .../components/CredentialContentDialog.vue | 66 +++ .../components/CredentialContentTable.vue | 169 ++++++ .../components/CredentialDialog.vue | 547 ++++++++++++++++++ .../components/CredentialScopeDialog.vue | 155 +++++ .../Credential/components/ImageViewer.vue | 126 ++++ .../views/admin/Space/Credential/index.vue | 472 +++++++++------ 15 files changed, 1522 insertions(+), 330 deletions(-) create mode 100644 frontend/src/assets/images/apigw.png create mode 100644 frontend/src/assets/images/apigw_access_token.png create mode 100644 frontend/src/scss/mixins/credentialScope.scss delete mode 100644 frontend/src/views/admin/Space/Credential/CredentialDialog.vue create mode 100644 frontend/src/views/admin/Space/Credential/components/CredentialContentDialog.vue create mode 100644 frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue create mode 100644 frontend/src/views/admin/Space/Credential/components/CredentialDialog.vue create mode 100644 frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue create mode 100644 frontend/src/views/admin/Space/Credential/components/ImageViewer.vue diff --git a/frontend/src/assets/images/apigw.png b/frontend/src/assets/images/apigw.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc6b29c54fd4ed4c64b391f4952e95e65e96cad GIT binary patch literal 72224 zcmce-XFQxw)IThZ)h(g~(R*7&kBHuh2o_OR@1hG)BCGe_iQZYg2dnq!okWe^iMr2{ z-+ll8ub$8I>UqO8*EMs_cTSr#XJ&)mDa&AEyud&~LBW=jm3)u<2Sq_aqXaxbzM%wc zq@tiazI~_oK?;FDoLyXzk}_Xi-yWY{93G#Yo?q_nAOGG(&~r-cAD%4#S{H&Mj7)5s znp=1H_!3fbxwr*xZ*LFf->j@`uB~q_EH3^2z4B-C&;G&TpRMh=`Gwux-HV*`QV-h= zBz45-?(V*kNA=O+Ap)^}_4jJ`7_onXFfvV?Kp=V$h=i1ev#hkO&Qiow!_3TFgo#Z; zL03-a`qur}t&W1+$bQ6lb?(yg?}X}!)6)}I6Iw5^-@~!Rd?aC>{&!3~Sv$KfpZX=VgOMkDYrlvZ!5f^{g%GMC=f3JOf{Y*_w zPc9MhSq&hbFXQ7ATw2-96PuY@0$p8Q=NISGbIYJt`Uz>JZXmvPc-Qq~RlsihpAT~osYcX!?U+gnD_y*t_N@RYVLdn?+^`gCDjf2kqF^TM0Xy-#l_LZ#OKiu0K6vKu@WDY_Q2uN4 z)?yR;fW`;cLS7Y@*ib}xT#A{w)=soB)%ecL%;%i&1VV~@SPioS~6<RVJ0DKaA@&oCzn?Z&;!#mh+Fs!!|3=1uP*8mO>99~x zs2CwgcZ!oEK|y(|3`ah)JbX)qK0-nHz;ORf5FP1o1&a6I9PS6dOS=C?+un3PJQDWM z^9j!RXBGU~z1u)!hnsgGQHWJ*8WAeW$K7c$Y;i8UXKi_P>H+jn0~As^a66j%&zp`s zx@J=1Cv+^yVXSCXNI0_UugHjTOpzS%iUm?(&LGSuC_W#%W}&6uw2eaB zeEc1GPshpkuNbNfZl1ryfSkVGZQs&wJW{1oe{Db2cB64y^`-HiirpL( zNXDpc3xFjt$@JX1+dgK2e?O(jFg51obvt#Ewb)+%g5q(a6ZH9V&_fF^(eVp&uWVlT za)n06tF@EF;i?VCtv@X+@b@i)c}{K}oLd$+P0G zhCN`E8=`L}qG1z~B0 z4Z~&n3G=6CQ**|BX0C22q~kz`g}sOyuHEr_JL{_PJ3<$G;E&$!7O2TqR@2t$k?F5E z``_}ihY#S)O(qPuXHzsjJG?n?iH7=#6>b_dJr4-$PhQNVg=BoD!pmEe`O#3iccjGt zzS&P46C0j(@Odn{zqQM{%tiZQaI9fX*{h(AkO{Kg?sq{=7n&yjOTJV(lhsD+Eywp( z`>W@oRpM~dJ-{?Jv$@9jR>JmrcN^8G%4eh@c<7Gb^lN8G3=>YI;Gg6_N0L2}7>_Kq zE`+p?jp0cczKbQr4EUFM`v_Zb#UnE)Mu);s1*q3N?A9G|!JP_%QyHWb`!& zi|Uiy@NbKPnI9Z(!zYU?*GcB>qJAYv*&F}KTHOD}ijKA#F``_9?mx#Cyc;aA&~Bfu zt0h3AT<5EA*L8*;!JhlRDF8<#F1m~L^V|@%CN&*aqh>0E{ac}CjMZ4mXVK<<88>u^ z9)(#7-uhVfOL{ElBz>?Z{AX0mVwBGho9Q$Kcx#&r;npX7|Is~y_W-$nSi!jkVzTHR zoHP&@iMLU(kT(9^{7s~#lZ9)&-1C%Hcq1vmXa46Zm ziz@Y>AWI9{>$d{0H>89N9X_0Vy6}~kH7JVY6x)2A9)jQB-X zMQ@BDJT>BxXdJ8`(j)vgy&lOJ$04D{Dy?){n681KA#k{QffFLI5e4F*7E<=KipuFk zSw3!!F?$gY`^v7sWOvbf3V*Hu7DeUNfv?GEFq|^RLW5M+v#37|L~+I{=ex9Lga-~y zWJG+&<&6g2`#V+O^E_H{_*lFRNs*r%zDB$3xws&m=|V|7Y}ny&z=VHJGgA-ONG{c0 z<&j5++{}WEOh9+SAdanCCzqBV-7|&60cLO8OwziBnwWz?JC#E}T@9W93$C%4@cO@t zjdwFiDm=B&!WfH+0u4F&*n<`Paau@Vgj5X3I$R$c%;N7KA%u^r-toNvKfWisw>COxEZtM~575!|G@S_U3H83lXwpqcdvoA1V8%%bm1kswppijSqW7N{w@*=^;}OG!0&#;`8q}6v=TxM;qCxc zG(&IKy51Kcet^~Bpe%D4INjnG87nCKt_Z5N1)Z|BosZ9rlZO9%*Z;QXMKtWQIZk!4 zr{pq!!Ti;vpOfT*ITN}Bt)+I}RX~d)dJ;3%YJK=K#uJNPxI6_P8=*W~FMUIf^@0O4 zUERkJN$Oo=qfFExbJJ)nZq-oLGHOf*CFq(4k<0t|yOGp`q>I$gBjnAtYImYPcs9As zOuuLimLpDHmz$@eTCzYcB?N5aK%c8(j#I7N4Dh7^)KI)*PxKUTSgg8%(f6k(;!}>+ zaVpw=&&|hE*WXS=|4D^t$eak464z$xDOO(2w_%LxbZUFgFHc)F7&xtAs!L~|sr zpfNo;)F82LaBC&fIHm{RN4=oIpAu7@p^O71@lf*l1qIba!E{Be*NSq?Fzt(RN?c=L zzVVcBE11Www8hpf+PjK*{!nP28k^Hm^(Q^lPsu6;?BLP=#y+$>C{P>; z_=`RqURM4`2yzA^ElJF1nP&tJ6B?og$t8-suy1_jXMxXm-V*~#3izA*<;tQ6YLjjd zQGm8n{emkH@2m>HpDaKod|p1$It+@gCQOG>(_;1

CtgnyF=6DfqS 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 E7banh-v9sr literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f4fbba4ae6c5464513e2089953724b6fb1dc16d0 GIT binary patch literal 77836 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 W#fvr*{^]+$/; const PACKAGE_NAME_REG = /^[^\d][\w]*?$/; // celery的crontab时间表达式正则表达式(分钟 小时 星期 日 月)(以空格分割) @@ -179,4 +200,5 @@ 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, + CREDENTIAL_TYPE_LIST, }; diff --git a/frontend/src/scss/mixins/credentialScope.scss b/frontend/src/scss/mixins/credentialScope.scss new file mode 100644 index 0000000000..9d15b08ad8 --- /dev/null +++ b/frontend/src/scss/mixins/credentialScope.scss @@ -0,0 +1,64 @@ +@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%); +} + +::v-deep .credential-dialog { + .bk-dialog-body { + max-height: 500px; + overflow-y: auto; + + .plus-shape-icon, + .minus-shape-icon { + color: #c4c6cc; + margin-right: 18px; + cursor: pointer; + + &.is-disabled { + color: #eaebf0; + cursor: not-allowed; + } + } + + .credential-dialog-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; + } + } +} diff --git a/frontend/src/store/modules/credentialConfig.js b/frontend/src/store/modules/credentialConfig.js index 78165eb2e3..26dd4ab4ad 100644 --- a/frontend/src/store/modules/credentialConfig.js +++ b/frontend/src/store/modules/credentialConfig.js @@ -17,6 +17,14 @@ export default { updateCredential({}, params) { return axios.patch(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); }, + // 获取凭证作用域 + loadCredentialScope({}, params) { + return axios.get(`api/space/admin/credential_config/${params.id}/list_scopes/?space_id=${params.space_id}`, params).then(response => response.data); + }, + // 凭证作用域更新接口 + updateCredentialScope({}, params) { + return axios.patch(`api/space/admin/credential_config/${params.id}/update_scopes/?space_id=${params.space_id}`, params).then(response => response.data); + }, deleteCredential({}, params) { return axios.delete(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); }, 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..160a2cdb8c --- /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/CredentialDialog.vue b/frontend/src/views/admin/Space/Credential/components/CredentialDialog.vue new file mode 100644 index 0000000000..1d28a0b02b --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/components/CredentialDialog.vue @@ -0,0 +1,547 @@ + + + + + diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue b/frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue new file mode 100644 index 0000000000..b4472d58a5 --- /dev/null +++ b/frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue @@ -0,0 +1,155 @@ + + + + + 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..bbb28cbbc5 100644 --- a/frontend/src/views/admin/Space/Credential/index.vue +++ b/frontend/src/views/admin/Space/Credential/index.vue @@ -5,218 +5,364 @@ theme="primary" :disabled="listLoading || !spaceId" @click="isDialogShow = true"> - {{ $t('新建') }} + {{ $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 }"> +

+ + + + + + From ef19a9fb98d4bd5835c895da08a8706b2f3d2c0a Mon Sep 17 00:00:00 2001 From: dengyh Date: Fri, 7 Nov 2025 18:03:28 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=E5=87=AD=E8=AF=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=87=8D=E6=9E=84=20--story=3D125449007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bkflow/apigw/serializers/credential.py | 47 +++-- bkflow/apigw/views/create_credential.py | 9 +- bkflow/space/credential/scope_validator.py | 41 +++-- .../migrations/0010_credential_scope_level.py | 23 +++ bkflow/space/models.py | 59 +++++-- bkflow/space/serializers.py | 10 +- bkflow/space/views.py | 161 +++++------------- .../credential/test_credential_model.py | 29 +++- .../credential/test_credential_resolver.py | 10 +- .../test_credential_scope_validator.py | 48 ++++-- 10 files changed, 233 insertions(+), 204 deletions(-) create mode 100644 bkflow/space/migrations/0010_credential_scope_level.py diff --git a/bkflow/apigw/serializers/credential.py b/bkflow/apigw/serializers/credential.py index d40d6029d0..c64c156fdb 100644 --- a/bkflow/apigw/serializers/credential.py +++ b/bkflow/apigw/serializers/credential.py @@ -20,7 +20,8 @@ from rest_framework import serializers from bkflow.space.credential import CredentialDispatcher -from bkflow.space.models import Credential, CredentialScope +from bkflow.space.models import Credential, CredentialScopeLevel +from bkflow.space.serializers import CredentialScopeSerializer class CredentialSerializer(serializers.ModelSerializer): @@ -42,37 +43,17 @@ class Meta: fields = "__all__" -class CredentialScopeSerializer(serializers.ModelSerializer): - """凭证作用域序列化器""" - - class Meta: - model = CredentialScope - fields = ["scope_type", "scope_value"] - - -class CredentialScopesChangeSerializer(serializers.Serializer): - """凭证作用域变更序列化器""" - - scopes = serializers.ListField( - child=CredentialScopeSerializer(), help_text=_("凭证作用域列表"), required=False, default=list - ) - unlimited = serializers.BooleanField(help_text=_("是否无限制"), required=False, default=False) - - def validate(self, attrs): - if attrs.get("unlimited"): - if attrs.get("scopes"): - raise serializers.ValidationError(_("无限制时不能设置作用域")) - - if not attrs.get("unlimited") and not attrs.get("scopes"): - raise serializers.ValidationError(_("作用域不能为空")) - return attrs - - class CreateCredentialSerializer(serializers.Serializer): name = serializers.CharField(help_text=_("凭证名称"), max_length=32, required=True) 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 ) @@ -82,6 +63,9 @@ def validate(self, attrs): credential_type = attrs.get("type") content = attrs.get("content") + 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() @@ -96,9 +80,18 @@ class UpdateCredentialSerializer(serializers.Serializer): 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,否则需要从实例获取 diff --git a/bkflow/apigw/views/create_credential.py b/bkflow/apigw/views/create_credential.py index 13ca044279..913bfb62a5 100644 --- a/bkflow/apigw/views/create_credential.py +++ b/bkflow/apigw/views/create_credential.py @@ -26,7 +26,7 @@ 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, CredentialScope +from bkflow.space.models import Credential, CredentialScope, CredentialScopeLevel @login_exempt @@ -50,14 +50,17 @@ def create_credential(request, space_id): # 提取作用域数据 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) + credential = Credential.create_credential( + **credential_data, space_id=space_id, creator=request.user.username, scope_level=scope_level + ) # 创建凭证作用域 - if scopes: + if scope_level == CredentialScopeLevel.PART.value and scopes: scope_objects = [ CredentialScope( credential_id=credential.id, diff --git a/bkflow/space/credential/scope_validator.py b/bkflow/space/credential/scope_validator.py index 7f869a4cef..8e3cad15ff 100644 --- a/bkflow/space/credential/scope_validator.py +++ b/bkflow/space/credential/scope_validator.py @@ -19,7 +19,7 @@ from django.utils.translation import ugettext_lazy as _ from bkflow.space.exceptions import CredentialScopeValidationError -from bkflow.space.models import CredentialScope +from bkflow.space.models import CredentialScope, CredentialScopeLevel def validate_credential_scope(credential, template_scope_type, template_scope_value): @@ -54,28 +54,39 @@ def filter_credentials_by_scope(credentials_queryset, scope_type, scope_value): :return: 过滤后的凭证查询集 """ # 获取所有凭证ID - all_credential_ids = set(credentials_queryset.values_list("id", flat=True)) + all_credential_ids = list(credentials_queryset.values_list("id", flat=True)) - # 获取有作用域限制的凭证ID - credentials_with_scope = set( - CredentialScope.objects.filter(credential_id__in=all_credential_ids).values_list("credential_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) - # 没有作用域限制的凭证ID(可以在任何地方使用) - credentials_without_scope = all_credential_ids - credentials_with_scope + # 模板有作用域时,需要过滤: + # 1. scope_level == ALL 的凭证(空间内开放,可以在任何地方使用) + # 2. scope_level == PART 且作用域匹配的凭证 - # 如果模板没有作用域,只返回没有设置作用域的凭证 - if not scope_type and not scope_value: - return credentials_queryset.filter(id__in=credentials_without_scope) + # 获取 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 + ) + ) - # 查找匹配当前作用域的凭证ID + # 查找 scope_level == PART 且作用域匹配的凭证ID matching_credential_ids = set( CredentialScope.objects.filter( - credential_id__in=credentials_with_scope, scope_type=scope_type, scope_value=scope_value + credential_id__in=part_level_credential_ids, scope_type=scope_type, scope_value=scope_value ).values_list("credential_id", flat=True) ) - # 返回:没有作用域限制的凭证 + 匹配当前作用域的凭证 - available_credential_ids = credentials_without_scope | matching_credential_ids + # 返回: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/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/models.py b/bkflow/space/models.py index 7cd8184282..9ccfc393e5 100644 --- a/bkflow/space/models.py +++ b/bkflow/space/models.py @@ -260,6 +260,14 @@ class CredentialType(Enum): CUSTOM = "CUSTOM" +class CredentialScopeLevel(Enum): + """凭证作用域级别""" + + ALL = "all" + PART = "part" + NONE = "none" + + class Credential(CommonModel): CREDENTIAL_CHOICES = [ (CredentialType.BK_APP.value, _("蓝鲸应用凭证")), @@ -268,10 +276,19 @@ class Credential(CommonModel): (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) + 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): @@ -291,7 +308,7 @@ 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): """ 创建一个凭证 @@ -301,6 +318,7 @@ def create_credential(cls, space_id, name, type, content, creator, desc=None): :param content: 凭证内容 :param creator: 创建者 :param desc: 凭证描述(可选) + :param scope_level: 作用域级别(可选,默认为 NONE) :return: 创建的凭证实例 """ @@ -308,6 +326,8 @@ def create_credential(cls, space_id, name, type, content, creator, desc=None): 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, @@ -316,6 +336,7 @@ 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 @@ -350,32 +371,38 @@ def has_scope(self): def can_use_in_scope(self, template_scope_type, template_scope_value): """ 检查凭证是否可以在指定作用域中使用 - 如果凭证没有设置作用域,则可以在任何作用域使用 - 如果模板没有作用域(scope_type和scope_value都为空),则可以使用任何凭证 - 否则,凭证的作用域必须匹配模板的作用域 :param self: 凭证实例 :param template_scope_type: 作用域类型 :param template_scope_value: 作用域值 :return: 如果可以使用返回 True,否则返回 False """ - if not self.has_scope(): - # 凭证没有设置作用域,不允许被使用 + # scope_level == NONE 的凭证不能使用 + if self.scope_level == CredentialScopeLevel.NONE.value: return False - if not template_scope_type and not template_scope_value: - # 模板没有作用域,可以使用任何凭证 + # scope_level == ALL 的凭证可以在任何地方使用 + if self.scope_level == CredentialScopeLevel.ALL.value: return True - # 检查是否有匹配的作用域 - return ( - self.get_scopes() - .filter( - scope_type=template_scope_type, - scope_value=template_scope_value, + # 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() ) - .exists() - ) + + # 默认不允许使用(向后兼容旧逻辑) + return False class Meta: verbose_name = _("空间凭证") diff --git a/bkflow/space/serializers.py b/bkflow/space/serializers.py index 5a20f12709..aaf22b985c 100644 --- a/bkflow/space/serializers.py +++ b/bkflow/space/serializers.py @@ -23,7 +23,7 @@ from bkflow.exceptions import ValidationError from bkflow.space.configs import SpaceConfigHandler, SpaceConfigValueType -from bkflow.space.models import Space, SpaceConfig +from bkflow.space.models import CredentialScope, Space, SpaceConfig logger = logging.getLogger(__name__) @@ -58,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")) diff --git a/bkflow/space/views.py b/bkflow/space/views.py index 90c143b69f..257d72500a 100644 --- a/bkflow/space/views.py +++ b/bkflow/space/views.py @@ -36,8 +36,6 @@ from bkflow.apigw.serializers.credential import ( CreateCredentialSerializer, - CredentialScopesChangeSerializer, - CredentialScopeSerializer, CredentialSerializer, UpdateCredentialSerializer, ) @@ -54,6 +52,7 @@ from bkflow.space.models import ( Credential, CredentialScope, + CredentialScopeLevel, CredentialType, Space, SpaceConfig, @@ -66,6 +65,7 @@ ) from bkflow.space.serializers import ( CredentialBaseQuerySerializer, + CredentialScopeSerializer, SpaceConfigBaseQuerySerializer, SpaceConfigBatchApplySerializer, SpaceConfigSerializer, @@ -74,7 +74,6 @@ from bkflow.utils.api_client import ApiGwClient, HttpRequestResult from bkflow.utils.mixins import BKFLOWDefaultPagination, BKFlowOrderingFilter from bkflow.utils.permissions import AdminPermission, AppInternalPermission -from bkflow.utils.serializer import params_valid from bkflow.utils.views import AdminModelViewSet, SimpleGenericViewSet logger = logging.getLogger("root") @@ -366,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) @@ -384,27 +416,18 @@ def create(self, request, *args, **kwargs): 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 ) - - # 创建凭证作用域 - scopes = credential_data.get("scopes", []) - 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) - 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: @@ -428,22 +451,7 @@ def partial_update(self, request, *args, **kwargs): updated_keys = list(serializer.validated_data.keys()) + ["updated_by", "update_at"] instance.save(update_fields=updated_keys) - # 更新凭证作用域 - if scopes_data is not None: - # 删除旧的作用域 - CredentialScope.objects.filter(credential_id=instance.id).delete() - # 创建新的作用域 - if scopes_data: - scope_objects = [ - CredentialScope( - credential_id=instance.id, - scope_type=scope.get("scope_type"), - scope_value=scope.get("scope_value"), - ) - for scope in scopes_data - ] - CredentialScope.objects.bulk_create(scope_objects) - + 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) @@ -452,91 +460,12 @@ def partial_update(self, request, *args, **kwargs): response_serializer = CredentialSerializer(instance) return Response(response_serializer.data, status=status.HTTP_200_OK) - @swagger_auto_schema( - method="get", - operation_summary="获取凭证作用域", - ) - @action(detail=True, methods=["get"]) - def list_scopes(self, request, pk=None, params=None): - """获取凭证的作用域列表""" - try: - credential = self.get_object() - except Credential.DoesNotExist as e: - err_msg = f"凭证不存在 {str(e)}" - logger.error(err_msg) - return Response({"error": err_msg}, status=status.HTTP_404_NOT_FOUND) - - # 获取凭证的所有作用域 - scopes = CredentialScope.objects.filter(credential_id=credential.id) - serializer = CredentialScopeSerializer(scopes, many=True) - - # 判断是否为无限制凭证(没有设置任何作用域) - if scopes.count() == 1 and scopes.first().scope_type is None and scopes.first().scope_value is None: - is_unlimited = True - else: - is_unlimited = False - - return Response( - { - "credential_id": credential.id, - "credential_name": credential.name, - "unlimited": is_unlimited, - "scopes": serializer.data, - } - ) - - @swagger_auto_schema( - methods=["put", "patch"], - operation_summary="更新凭证作用域", - request_body=CredentialScopesChangeSerializer, - ) - @action(detail=True, methods=["put", "patch"]) - @params_valid(CredentialScopesChangeSerializer) - def update_scopes(self, request, pk=None, params=None): - """更新凭证作用域""" - try: - instance = self.get_object() - except Credential.DoesNotExist as e: - err_msg = f"更新凭证不存在 {str(e)}" - logger.error(err_msg) - return Response(err_msg, status=404) - - # 验证scopes数据 - params = params or {} - if params.get("unlimited"): - scopes_data = [{"scope_type": None, "scope_value": None}] - else: - scopes_data = params.get("scopes", []) - + def destroy(self, request, *args, **kwargs): try: with transaction.atomic(): - # 删除旧的作用域 + instance = self.get_object() CredentialScope.objects.filter(credential_id=instance.id).delete() - # 创建新的作用域 - scope_objects = [ - CredentialScope( - credential_id=instance.id, - scope_type=scope.get("scope_type"), - scope_value=scope.get("scope_value"), - ) - for scope in scopes_data - ] - CredentialScope.objects.bulk_create(scope_objects) - except DatabaseError as e: - err_msg = f"更新凭证作用域失败 {str(e)}" - logger.error(err_msg) - return Response(exception=True, data={"detail": err_msg}) - - # 返回更新后的凭证信息 - credential_scopes = [] - for scope_object in scope_objects: - credential_scopes.append(CredentialScopeSerializer(scope_object).data) - return Response(credential_scopes, status=status.HTTP_200_OK) - - def destroy(self, request, *args, **kwargs): - try: - instance = self.get_object() - instance.hard_delete() + instance.hard_delete() except Credential.DoesNotExist as e: err_msg = f"删除凭证不存在 {str(e)}" logger.error(err_msg) diff --git a/tests/interface/credential/test_credential_model.py b/tests/interface/credential/test_credential_model.py index 735070d7dc..303111f842 100644 --- a/tests/interface/credential/test_credential_model.py +++ b/tests/interface/credential/test_credential_model.py @@ -19,7 +19,13 @@ import pytest from django.db import IntegrityError -from bkflow.space.models import Credential, CredentialScope, CredentialType, Space +from bkflow.space.models import ( + Credential, + CredentialScope, + CredentialScopeLevel, + CredentialType, + Space, +) @pytest.mark.django_db @@ -212,6 +218,10 @@ def test_can_use_in_scope_without_scope(self, test_credential): 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") @@ -226,18 +236,23 @@ def test_can_use_in_scope_with_matching_scope(self, test_credential): test_credential.get_scopes().delete() def test_can_use_in_scope_with_template_no_scope(self, test_credential): - """测试模板没有作用域时,有作用域的凭证也可以使用""" - # 添加作用域 - CredentialScope.objects.create(credential_id=test_credential.id, scope_type="project", scope_value="project_1") + """测试模板没有作用域时,scope_level == ALL 的凭证可以使用""" + # 设置 scope_level 为 ALL(空间内开放) + test_credential.scope_level = CredentialScopeLevel.ALL.value + test_credential.save() - # 模板没有作用域(都为 None),有作用域的凭证也可以使用 + # 模板没有作用域(都为 None),scope_level == ALL 的凭证可以使用 assert test_credential.can_use_in_scope(None, None) is True - # 清理 - test_credential.get_scopes().delete() + # 有作用域时也可以使用 + 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") diff --git a/tests/interface/credential/test_credential_resolver.py b/tests/interface/credential/test_credential_resolver.py index 50be60bbeb..fa46345405 100644 --- a/tests/interface/credential/test_credential_resolver.py +++ b/tests/interface/credential/test_credential_resolver.py @@ -23,7 +23,13 @@ CredentialNotFoundError, CredentialScopeValidationError, ) -from bkflow.space.models import Credential, CredentialScope, CredentialType, Space +from bkflow.space.models import ( + Credential, + CredentialScope, + CredentialScopeLevel, + CredentialType, + Space, +) @pytest.mark.django_db @@ -46,6 +52,7 @@ def test_credential(self, test_space): 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") @@ -61,6 +68,7 @@ def scoped_credential(self, test_space): 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 diff --git a/tests/interface/credential/test_credential_scope_validator.py b/tests/interface/credential/test_credential_scope_validator.py index 4bc1951246..dc895017a5 100644 --- a/tests/interface/credential/test_credential_scope_validator.py +++ b/tests/interface/credential/test_credential_scope_validator.py @@ -23,7 +23,13 @@ validate_credential_scope, ) from bkflow.space.exceptions import CredentialScopeValidationError -from bkflow.space.models import Credential, CredentialScope, CredentialType, Space +from bkflow.space.models import ( + Credential, + CredentialScope, + CredentialScopeLevel, + CredentialType, + Space, +) @pytest.mark.django_db @@ -46,6 +52,7 @@ def credential_with_scope(self, test_space): 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 @@ -53,13 +60,14 @@ def credential_with_scope(self, test_space): @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() @@ -78,16 +86,16 @@ def test_validate_credential_with_non_matching_scope(self, credential_with_scope assert "不能在作用域" in str(exc_info.value) def test_validate_credential_without_scope_fails(self, credential_without_scope): - """测试验证没有作用域的凭证(应该失败)""" - # 凭证没有作用域,不允许使用 - with pytest.raises(CredentialScopeValidationError): - validate_credential_scope(credential_without_scope, "project", "project_1") + """测试验证 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): - """测试在没有作用域的模板中使用有作用域的凭证""" - # 模板没有作用域,有作用域的凭证可以使用 - result = validate_credential_scope(credential_with_scope, None, None) - assert result is True + """测试在没有作用域的模板中使用 scope_level == PART 的凭证(应该失败)""" + # 模板没有作用域,scope_level == PART 的凭证不能使用 + with pytest.raises(CredentialScopeValidationError): + validate_credential_scope(credential_with_scope, None, None) @pytest.mark.django_db @@ -104,32 +112,35 @@ def test_space(self): @pytest.fixture def setup_credentials(self, test_space): """创建测试凭证""" - # 1. 没有作用域的凭证 + # 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. 有匹配作用域的凭证 + # 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. 有不匹配作用域的凭证 + # 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") @@ -144,18 +155,19 @@ 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() == 3 + 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 和 matching + # 应该只有 2 个凭证:no_scope (ALL) 和 matching_scope (PART 且匹配) assert filtered.count() == 2 names = [c.name for c in filtered] assert "no_scope" in names @@ -166,7 +178,7 @@ 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 From cf51face662da5c4fb7f5f42ee285cae5c3eac9b Mon Sep 17 00:00:00 2001 From: lhzzforever Date: Fri, 7 Nov 2025 18:07:18 +0800 Subject: [PATCH 07/10] =?UTF-8?q?feature(credential):=20=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E8=AE=BE=E8=AE=A1=E6=95=B4=E6=94=B9(?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E7=BC=96=E8=BE=91=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E3=80=81=E6=96=B0=E5=A2=9E=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E9=80=89=E9=A1=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feature(credential): 去掉编辑作用域模块的i18n翻译 --story=125449007 --- frontend/src/api/ajax.js | 1 - frontend/src/config/i18n/cn.js | 8 +- frontend/src/config/i18n/en.js | 7 +- frontend/src/constants/index.js | 32 +- frontend/src/scss/mixins/credentialScope.scss | 16 +- .../src/store/modules/credentialConfig.js | 16 +- .../components/CredentialContentTable.vue | 2 +- .../components/CredentialScopeDialog.vue | 155 ------ ...dentialDialog.vue => CredentialSlider.vue} | 455 +++++++++++------- .../views/admin/Space/Credential/index.vue | 39 +- 10 files changed, 336 insertions(+), 395 deletions(-) delete mode 100644 frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue rename frontend/src/views/admin/Space/Credential/components/{CredentialDialog.vue => CredentialSlider.vue} (50%) 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/config/i18n/cn.js b/frontend/src/config/i18n/cn.js index 99b4ebc8e8..45e2e91182 100644 --- a/frontend/src/config/i18n/cn.js +++ b/frontend/src/config/i18n/cn.js @@ -929,13 +929,11 @@ const cn = { 蓝鲸应用凭证: '蓝鲸应用凭证', '蓝鲸 Access Token 凭证': '蓝鲸 Access Token 凭证', 'Basic Auth': 'Basic Auth', - 自定义: '自定义', 值类型: '值类型', 表单模式: '表单模式', json模式: 'json模式', 插件名称: '插件名称', 管理员: '管理员', - 授权状态: '授权状态', 使用范围: '使用范围', 授权状态修改时间: '授权状态修改时间', 授权状态修改人: '授权状态修改人', @@ -1070,10 +1068,12 @@ const cn = { 凭证内容: '凭证内容', 作用域值: '作用域值', 作用域类型: '作用域类型', + 开放范围: '开放范围', + 不开放: '不开放', + 全部流程: '全部流程', + 按作用域开放: '按作用域开放', 查看凭证获取指引: '查看凭证获取指引', - 编辑凭证作用域: '编辑凭证作用域', 蓝鲸应用认证: '蓝鲸应用认证', - 对空间内所有流程开放: '对空间内所有流程开放', 名称不能为空: '名称不能为空', 至少保留一个: '至少保留一个', bk_app_code不能为空: 'bk_app_code不能为空', diff --git a/frontend/src/config/i18n/en.js b/frontend/src/config/i18n/en.js index 2607e995d6..8001a87697 100644 --- a/frontend/src/config/i18n/en.js +++ b/frontend/src/config/i18n/en.js @@ -1063,15 +1063,16 @@ const en = { '请输入值或 $ 选择变量': 'Enter value or $ to select variable', 查看内容: 'View content', 作用域: 'Scope', - 自定义: 'Custom', 凭证指引: 'Credential guidelines', 凭证内容: 'Credential content', 作用域值: 'Scope value', 作用域类型: 'Scope type', + 开放范围: 'Open scope', + 不开放: 'Not open', + 全部流程: 'All processes', + 按作用域开放: 'Open by scope', 查看凭证获取指引: 'View credential acquisition guide', - 编辑凭证作用域: 'Edit credential scope', 蓝鲸应用认证: 'BlueKing App Authentication', - 对空间内所有流程开放: 'Open to all processes within the space', 至少保留一个: 'Keep at least one', 名称不能为空: 'The name cannot be empty', bk_app_code不能为空: 'bk_app_code cannot be empty', diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js index f5e128aecc..3d09dbed70 100644 --- a/frontend/src/constants/index.js +++ b/frontend/src/constants/index.js @@ -185,6 +185,21 @@ const CREDENTIAL_TYPE_LIST = [ }, ]; +// 凭证开放范围 +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]*?$/; @@ -197,8 +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 index 9d15b08ad8..bdc1f3907c 100644 --- a/frontend/src/scss/mixins/credentialScope.scss +++ b/frontend/src/scss/mixins/credentialScope.scss @@ -11,10 +11,9 @@ transform: translate(3px, -50%); } -::v-deep .credential-dialog { - .bk-dialog-body { - max-height: 500px; - overflow-y: auto; +.credential-slider { + ::v-deep .credential-slider-content { + padding: 24px 40px; .plus-shape-icon, .minus-shape-icon { @@ -28,7 +27,7 @@ } } - .credential-dialog-table { + .credential-content-table { thead { th:not(.is-last) { .bk-table-header-label { @@ -61,4 +60,11 @@ 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 26dd4ab4ad..9f7114a447 100644 --- a/frontend/src/store/modules/credentialConfig.js +++ b/frontend/src/store/modules/credentialConfig.js @@ -14,17 +14,17 @@ 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); }, - // 获取凭证作用域 - loadCredentialScope({}, params) { - return axios.get(`api/space/admin/credential_config/${params.id}/list_scopes/?space_id=${params.space_id}`, params).then(response => response.data); - }, - // 凭证作用域更新接口 - updateCredentialScope({}, params) { - return axios.patch(`api/space/admin/credential_config/${params.id}/update_scopes/?space_id=${params.space_id}`, params).then(response => response.data); - }, deleteCredential({}, params) { return axios.delete(`api/space/admin/credential_config/${params.id}/?space_id=${params.space_id}`, params).then(response => response.data); }, diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue b/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue index 160a2cdb8c..856fd55f99 100644 --- a/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue +++ b/frontend/src/views/admin/Space/Credential/components/CredentialContentTable.vue @@ -2,7 +2,7 @@
diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue b/frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue deleted file mode 100644 index b4472d58a5..0000000000 --- a/frontend/src/views/admin/Space/Credential/components/CredentialScopeDialog.vue +++ /dev/null @@ -1,155 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/Space/Credential/components/CredentialDialog.vue b/frontend/src/views/admin/Space/Credential/components/CredentialSlider.vue similarity index 50% rename from frontend/src/views/admin/Space/Credential/components/CredentialDialog.vue rename to frontend/src/views/admin/Space/Credential/components/CredentialSlider.vue index 1d28a0b02b..743b20d8fd 100644 --- a/frontend/src/views/admin/Space/Credential/components/CredentialDialog.vue +++ b/frontend/src/views/admin/Space/Credential/components/CredentialSlider.vue @@ -1,191 +1,220 @@ + diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue index 5a1bcbd108..79c47f44d3 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('访问凭证') }}

+ +
'); } - this.updateBasicInfo({ desc }); + if (Object.prototype.hasOwnProperty.call(resp.data, 'credentials')) { + this.updateBasicInfo({ desc, isHaveCredentials: true }); + } 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 +842,7 @@ let code = ''; let desc = ''; let version = ''; + let credentials = null; // 节点已选择标准插件 if (component.code && !this.isNotExistAtomOrVersion) { // 节点插件存在 if (component.code === 'remote_plugin') { @@ -823,6 +852,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 +888,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 +1212,9 @@ }); this.$refs.basicInfo && this.$refs.basicInfo.validate(); // 清除节点保存报错时的错误信息 }, + onChangeCredential(val,) { + this.updateBasicInfo({ credentials: val }); + }, /** * 更新基础信息 * 填写基础信息表单,切换插件/子流程,选择插件版本,子流程更新 @@ -1419,7 +1453,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 +1464,13 @@ }, // 节点配置面板表单校验,基础信息和输入参数 validate() { - return this.$refs.basicInfo.validate().then(() => { + return this.$refs.basicInfo.validate().then(async () => { + if (this.$refs.accessCredential) { + const validations = await Promise.all([this.$refs.accessCredential.validate()]); + if (!validations) { + return false; + }; + } if (this.$refs.inputParams) { let result = this.$refs.inputParams.validate(); // api插件额外校验json类型 @@ -1542,6 +1581,7 @@ autoRetry, timeoutConfig, executor_proxy, + credentials, } = this.basicInfo; // 设置标准插件节点在 activity 的 component.data 值 let data = {}; @@ -1556,6 +1596,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; From c44773c56c3e3ff88b1581b6fc80a1e4e6d4fbc2 Mon Sep 17 00:00:00 2001 From: Mianhuatang8 <2542880657@qq.com> Date: Fri, 28 Nov 2025 10:42:30 +0800 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=E8=8A=82=E7=82=B9=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=B7=BB=E5=8A=A0=E5=87=AD=E8=AF=81=E9=80=89=E6=8B=A9?= =?UTF-8?q?=20--story=3D125449007=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 节点配置添加凭证选择 --story=125449007 # Reviewed, transaction id: 66075 * fix: 关闭label提示下划线 --story=125449007 # Reviewed, transaction id: 66078 * feat: 节点配置添加凭证选择 --story=125449007 # Reviewed, transaction id: 66104 * fix: 凭证选择校验优化 --story=125449007 # Reviewed, transaction id: 66163 --- .../NodeConfig/AccessCredential.vue | 77 ++++++++----------- .../TemplateEdit/NodeConfig/NodeConfig.vue | 23 +++++- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/AccessCredential.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/AccessCredential.vue index b57d2562fc..b274e00090 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/AccessCredential.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/AccessCredential.vue @@ -2,28 +2,30 @@

+ :rules="credentialRules"> + :desc="item.description" + :rules="credentialRules.curCredential" + :property="'curCredential.' + index + '.value'"> @@ -32,6 +34,7 @@ - diff --git a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue index 79c47f44d3..fbcd619d09 100644 --- a/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue +++ b/frontend/src/views/template/TemplateEdit/NodeConfig/NodeConfig.vue @@ -237,7 +237,6 @@ import jsonFormSchema from '@/utils/jsonFormSchema.js'; import copy from '@/mixins/copy.js'; import AccessCredential from './AccessCredential.vue'; -import { async } from '@antv/x6/lib/registry/marker/async'; export default { name: 'NodeConfig', @@ -556,7 +555,13 @@ import { async } from '@antv/x6/lib/registry/marker/async'; this.inputsRenderConfig = renderConfig; await this.getPluginDetail(); if (this.nodeConfig.component.credentials) { - this.updateBasicInfo({ credentials: this.nodeConfig.component.credentials }); + const backfillData = this.basicInfo.processCredentials.map((item) => { + 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); @@ -678,7 +683,17 @@ import { async } from '@antv/x6/lib/registry/marker/async'; desc = descList.join('
'); } if (Object.prototype.hasOwnProperty.call(resp.data, 'credentials')) { - this.updateBasicInfo({ desc, isHaveCredentials: true }); + 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 }); } @@ -1466,7 +1481,7 @@ import { async } from '@antv/x6/lib/registry/marker/async'; validate() { return this.$refs.basicInfo.validate().then(async () => { if (this.$refs.accessCredential) { - const validations = await Promise.all([this.$refs.accessCredential.validate()]); + const validations = await this.$refs.accessCredential.validate(); if (!validations) { return false; }; From 4bb7eab4b94baf3a77904488670bf23e29f7aac5 Mon Sep 17 00:00:00 2001 From: dengyh Date: Tue, 2 Dec 2025 20:41:40 +0800 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E5=87=AD=E8=AF=81=E7=9A=84=E7=B1=BB=E5=9E=8B=E6=8E=A8?= =?UTF-8?q?=E6=96=AD=20--story=3D125449007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0011_set_credential_type.py | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 bkflow/space/migrations/0011_set_credential_type.py 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), + ]