Commit c769a7c4 by Finley Ge Committed by GitHub

fix(security): harden system and temporary tool secrets (#7298)

* fix(security): harden system and temporary tool secrets

Synchronize toolset secrets from parent records, clear temporary values when switching secret sources, and keep production system secrets out of debug tool flows.

Add regression coverage for parent-child authority, debug fail-closed behavior, and app secret normalization.

* fix(security): encrypt system tool secrets

Store plugin system secrets as AES-GCM wrappers, mask configured values in administrator responses, and keep legacy plaintext and inputListVal records readable during migration. Update the administrator form to edit write-only markers without sending stored ciphertext back to the server.

* fix(ui): use plain input for system secrets

Keep system secret values visible while editing and continue to replace saved values with the write-only configured marker after reload.

* refactor(workflow): simplify workflow configuration logic
parent fc7603b2
......@@ -5,6 +5,7 @@ import type { SystemPluginToolCollectionType } from '../../../plugin/tool/type';
import { PluginStatusEnum } from '../../../plugin/type';
import { SystemToolSystemSecretStatusEnum } from './constants';
import type { SystemToolListItemType } from './type';
import { isDebugToolSource } from '../utils';
type SystemToolConfigLike = SystemPluginToolCollectionType & {
toObject?: () => SystemPluginToolCollectionType;
......@@ -87,13 +88,16 @@ export const SystemToolCodec = {
attachToolConfig({
tool,
config,
lang
lang,
source
}: {
tool: ToolListItemType;
config?: SystemPluginToolCollectionType;
lang?: `${LangEnum}`;
source?: string;
}): SystemToolListItemType {
const configuredSecretsVal = this.getConfiguredSecretsVal(config);
const isDebugSource = isDebugToolSource(source) || isDebugToolSource(tool.source);
const configuredSecretsVal = isDebugSource ? undefined : this.getConfiguredSecretsVal(config);
const hasSystemSecret = !!configuredSecretsVal;
return {
......
......@@ -32,3 +32,6 @@ export enum SystemToolSystemSecretStatusEnum {
configured = 'configured',
unconfigured = 'unconfigured'
}
/** 管理员配置页使用的 write-only 标记,表示该字段已有系统密钥但不回显密文。 */
export const SystemToolSecretMaskedValue = '__FASTGPT_SYSTEM_SECRET_MASKED__';
import { type AppSchemaType } from '@fastgpt/global/core/app/type';
import { AppTypeEnum } from '@fastgpt/global/core/app/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { MongoApp } from './schema';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { encryptSecretValue, storeSecretValue } from '../../common/secret/utils';
import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { getClientToolPreviewNode } from './tool/utils/client';
import { formatToolInputSecrets } from './tool/secretConfig';
import { MongoEvaluation } from './evaluation/evalSchema';
import { removeEvaluationJob } from './evaluation/mq';
import { MongoOutLink } from '../../support/outLink/schema';
......@@ -51,7 +49,7 @@ const logger = getLogger(LogCategories.MODULE.APP.FOLDER);
* 2. Skill: 统一数据结构为 { skillId: string }[]。
* 2. 敏感信息(如 Header Secret、密码类型输入、系统工具手动配置的密钥)进行加密存储。
*/
export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }) => {
export const beforeUpdateAppFormat = async ({ nodes }: { nodes?: StoreNodeItemType[] }) => {
if (!nodes) return;
const StoredSelectedDatasetSchema = z.object({
......@@ -79,27 +77,8 @@ export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }
// Format header secret
node.inputs.forEach((input) => {
formatToolInputSecrets({ inputs: [input] });
if (nodeInputIsReference(input)) return;
// 敏感信息
if (input.key === NodeInputKeyEnum.headerSecret && typeof input.value === 'object') {
input.value = storeSecretValue(input.value);
}
if (input.renderTypeList?.includes(FlowNodeInputTypeEnum.password)) {
input.value = encryptSecretValue(input.value);
}
if (input.key === NodeInputKeyEnum.systemInputConfig && typeof input.value === 'object') {
input.inputList?.forEach((inputItem) => {
if (
inputItem.inputType === 'secret' &&
input.value?.type === SystemToolSecretInputTypeEnum.manual &&
input.value?.value
) {
input.value.value[inputItem.key] = encryptSecretValue(input.value.value[inputItem.key]);
}
});
}
// 知识库
if (isDatasetNode) {
// Agent
......@@ -124,6 +103,54 @@ export const beforeUpdateAppFormat = ({ nodes }: { nodes?: StoreNodeItemType[] }
}
});
});
await Promise.all(
nodes.map(async (node) => {
if (node.flowNodeType !== FlowNodeTypeEnum.agent) return;
const selectedToolsInput = node.inputs.find(
(input) => input.key === NodeInputKeyEnum.selectedTools
);
if (!selectedToolsInput || nodeInputIsReference(selectedToolsInput)) return;
if (!Array.isArray(selectedToolsInput.value)) return;
await Promise.all(
selectedToolsInput.value.map(async (selectedTool: any) => {
if (!selectedTool?.id || !selectedTool.config) return;
try {
const preview = await getClientToolPreviewNode({
appId: selectedTool.id,
versionId: selectedTool.version,
source: selectedTool.source
});
const inputMap = new Map(preview.inputs.map((input) => [input.key, input]));
const configInputs = Object.keys(selectedTool.config)
.map((key) => inputMap.get(key))
.filter((input): input is (typeof preview.inputs)[number] => !!input);
configInputs.forEach((input) => {
input.value = selectedTool.config[input.key];
});
formatToolInputSecrets({ inputs: configInputs });
configInputs.forEach((input) => {
selectedTool.config[input.key] = input.value;
});
} catch {
// 工具已删除或暂时不可用时,至少清理嵌套 system/team 临时值。
const systemInput = selectedTool.config.system_input_config;
if (
systemInput &&
typeof systemInput === 'object' &&
systemInput.type !== SystemToolSecretInputTypeEnum.manual
) {
delete systemInput.value;
}
}
})
);
})
);
};
/**
......
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { encryptSecretValue, storeSecretValue } from '../../../common/secret/utils';
/**
* 格式化工具输入中的敏感值。
* 保存和详情读取都经过这里,确保 Agent 嵌套工具与普通工具使用一致的密钥规则。
*/
export const formatToolInputSecrets = ({ inputs }: { inputs: FlowNodeInputItemType[] }) => {
inputs.forEach((input) => {
if (
input.key === NodeInputKeyEnum.systemInputConfig &&
typeof input.value === 'object' &&
input.value !== null
) {
if (input.value.type !== SystemToolSecretInputTypeEnum.manual) {
delete input.value.value;
} else if (
input.value.value &&
typeof input.value.value === 'object' &&
!Array.isArray(input.value.value)
) {
input.inputList?.forEach((inputItem) => {
if (inputItem.inputType !== 'secret') return;
const value = input.value.value?.[inputItem.key];
input.value.value[inputItem.key] = encryptSecretValue(value);
});
}
}
if (input.key === NodeInputKeyEnum.headerSecret && typeof input.value === 'object') {
input.value = storeSecretValue(input.value);
}
if (input.renderTypeList?.includes(FlowNodeInputTypeEnum.password)) {
input.value = encryptSecretValue(input.value);
}
});
};
import { SystemToolSecretMaskedValue } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { type InputConfigType } from '@fastgpt/global/core/workflow/type/io';
import { decryptSecret, encryptSecret } from '../../../../common/secret/aes256gcm';
type SecretsVal = Record<string, unknown>;
type SecretValueLike = {
secret?: unknown;
value?: unknown;
};
const isMaskedValue = (value: unknown) => value === SystemToolSecretMaskedValue;
const isSecretValueLike = (value: unknown): value is SecretValueLike => {
return typeof value === 'object' && value !== null && ('secret' in value || 'value' in value);
};
const isConfiguredValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return false;
if (isMaskedValue(value)) return true;
if (isSecretValueLike(value)) {
return (value.value !== undefined && value.value !== '') || !!value.secret;
}
return true;
};
const isEncryptedValue = (value: unknown): value is { secret: string; value?: string } => {
return isSecretValueLike(value) && typeof value.secret === 'string' && value.secret.length > 0;
};
const encryptValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return value;
if (isEncryptedValue(value)) return value;
if (isSecretValueLike(value)) {
if (typeof value.value !== 'string' || value.value === '') return '';
return {
secret: encryptSecret(value.value),
value: ''
};
}
return {
secret: encryptSecret(String(value)),
value: ''
};
};
const decryptValue = (value: unknown) => {
if (!isSecretValueLike(value)) return value;
if (typeof value.value === 'string' && value.value !== '') return value.value;
if (!isEncryptedValue(value)) return value.value ?? '';
try {
return decryptSecret(value.secret);
} catch {
// 兼容历史非 AES 密钥结构,避免一次坏字段导致整个工具无法运行。
return value.value ?? '';
}
};
/** 从插件 secret schema 生成需要加密的字段集合。 */
export const getSystemToolSecretKeys = (inputList?: InputConfigType[]) =>
new Set((inputList ?? []).filter((item) => item.inputType === 'secret').map((item) => item.key));
/** 将管理员提交的系统密钥字段加密,并保留未编辑的已有字段。 */
export const encryptSystemToolSecrets = ({
secretsVal,
existingSecretsVal,
secretKeys
}: {
secretsVal?: SecretsVal | null;
existingSecretsVal?: SecretsVal;
secretKeys: Set<string>;
}) => {
if (secretsVal === null || secretsVal === undefined) return secretsVal;
const result: SecretsVal = { ...(existingSecretsVal ?? {}) };
Object.entries(secretsVal).forEach(([key, value]) => {
if (!secretKeys.has(key)) {
result[key] = value;
return;
}
if (isMaskedValue(value)) {
const existingValue = existingSecretsVal?.[key];
if (isConfiguredValue(existingValue)) {
result[key] = encryptValue(existingValue);
} else {
delete result[key];
}
return;
}
if (value === undefined || value === null || value === '') {
delete result[key];
return;
}
result[key] = encryptValue(value);
});
return result;
};
/** 管理员详情只返回已配置标记,禁止把系统密钥明文或密文回传给前端。 */
export const maskSystemToolSecrets = ({
secretsVal,
secretKeys
}: {
secretsVal?: SecretsVal;
secretKeys: Set<string>;
}) => {
if (!secretsVal) return secretsVal;
return Object.fromEntries(
Object.entries(secretsVal).map(([key, value]) => [
key,
secretKeys.has(key) && isConfiguredValue(value) ? SystemToolSecretMaskedValue : value
])
);
};
/** runtime 使用的系统密钥值,兼容旧明文并解密新格式。 */
export const decryptSystemToolSecrets = (secretsVal?: SecretsVal) => {
if (!secretsVal) return secretsVal;
return Object.fromEntries(
Object.entries(secretsVal).flatMap(([key, value]) => {
if (isMaskedValue(value)) return [];
return [[key, decryptValue(value)]];
})
);
};
......@@ -40,6 +40,11 @@ import type { PluginPermissionEnumType } from '@fastgpt/global/sdk/fastgpt-plugi
import { Types } from '../../../../common/mongo';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { normalizeWorkflowToolInputsDefaultMode } from '@fastgpt/global/core/app/tool/workflowTool/utils';
import {
decryptSystemToolSecrets,
getSystemToolSecretKeys,
maskSystemToolSecrets
} from './secrets';
type SystemToolRuntimeType = {
id: string;
......@@ -325,7 +330,8 @@ export class SystemToolRepo {
const item = SystemToolCodec.attachToolConfig({
tool,
config: getFirstSystemToolConfig(DBPluginsMap, tool.pluginId),
lang
lang,
source: tool.source
});
return {
......@@ -356,13 +362,15 @@ export class SystemToolRepo {
version,
source: toolSource = 'system',
lang,
fallbackLatestVersion = false
fallbackLatestVersion = false,
maskSecrets = false
}: {
pluginId: string;
version?: string;
source?: string;
lang?: `${LangEnum}`;
fallbackLatestVersion?: boolean;
maskSecrets?: boolean;
}): Promise<SystemToolDetailType> => {
const isDebugSource = isDebugToolSource(toolSource);
const { pluginId: rawPluginId, source: idSource } = parseSystemToolId({
......@@ -490,8 +498,24 @@ export class SystemToolRepo {
childPluginId ? child!.outputSchema : tool.outputSchema
);
const secrets = jsonSchema2SecretInput({ jsonSchema: secretSchema });
const configuredSecretsVal = SystemToolCodec.getConfiguredSecretsVal(dbTool);
const hasSystemSecret = !!configuredSecretsVal;
const secretKeys = getSystemToolSecretKeys(secrets);
const parentDbTool = await getParentSystemToolConfig({
pluginId,
idSource,
parentPluginId
});
const secretConfig = parentDbTool ?? dbTool;
const configuredSecretsVal = isDebugSource
? undefined
: SystemToolCodec.getConfiguredSecretsVal(secretConfig);
const hasSystemSecret = !isDebugSource && !!configuredSecretsVal;
const visibleSecretsVal =
maskSecrets && !isDebugSource
? maskSystemToolSecrets({
secretsVal: configuredSecretsVal,
secretKeys
})
: configuredSecretsVal;
const toolDetail: SystemToolDetailType = {
id: pluginId,
......@@ -503,7 +527,7 @@ export class SystemToolRepo {
hasSecret: !!secrets?.length,
hasSystemSecret
}),
secretsVal: configuredSecretsVal,
secretsVal: visibleSecretsVal,
hasTokenFee: dbTool?.hasTokenFee ?? false,
intro:
dbTool?.customConfig?.intro ??
......@@ -797,7 +821,11 @@ export class SystemToolRepo {
version: tool.version,
currentCost: dbTool?.currentCost ?? 0,
systemKeyCost: dbTool?.systemKeyCost ?? 0,
secretsVal: isDebugSource ? undefined : SystemToolCodec.getConfiguredSecretsVal(dbTool),
secretsVal: isDebugSource
? undefined
: decryptSystemToolSecrets(
SystemToolCodec.getConfiguredSecretsVal(parentDbTool ?? dbTool)
),
permissions: tool.permission
};
}
......@@ -807,7 +835,9 @@ export class SystemToolRepo {
version,
currentCost: dbTool.currentCost ?? 0,
systemKeyCost: dbTool.systemKeyCost ?? 0,
secretsVal: SystemToolCodec.getConfiguredSecretsVal(dbTool)
secretsVal: isDebugSource
? undefined
: decryptSystemToolSecrets(SystemToolCodec.getConfiguredSecretsVal(parentDbTool ?? dbTool))
};
};
......
......@@ -33,6 +33,7 @@ import type {
SelectedDatasetType
} from '@fastgpt/global/core/workflow/type/io';
import { normalizeWorkflowToolInputsDefaultMode } from '@fastgpt/global/core/app/tool/workflowTool/utils';
import { formatToolInputSecrets } from './tool/secretConfig';
import z from 'zod';
/**
......@@ -328,6 +329,8 @@ export async function rewriteAppWorkflowToDetail({
};
});
formatToolInputSecrets({ inputs: mergedInputs });
return {
...data,
source: tool.source ?? data.source,
......
import { describe, expect, it } from 'vitest';
import { SystemToolSecretMaskedValue } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { decryptSecret, encryptSecret } from '@fastgpt/service/common/secret/aes256gcm';
import {
decryptSystemToolSecrets,
encryptSystemToolSecrets,
getSystemToolSecretKeys,
maskSystemToolSecrets
} from '@fastgpt/service/core/app/tool/systemTool/secrets';
const secretKeys = new Set(['apiKey']);
describe('system tool secrets', () => {
it('encrypts new secret values and leaves non-secret config values unchanged', () => {
const stored = encryptSystemToolSecrets({
secretsVal: { apiKey: 'new-api-key', region: 'us' },
secretKeys
});
expect(stored).toMatchObject({
apiKey: { value: '' },
region: 'us'
});
expect(stored.apiKey).not.toBe('new-api-key');
expect(decryptSystemToolSecrets(stored)).toEqual({
apiKey: 'new-api-key',
region: 'us'
});
});
it('preserves an unedited masked value and migrates legacy plaintext', () => {
const stored = encryptSystemToolSecrets({
secretsVal: { apiKey: SystemToolSecretMaskedValue },
existingSecretsVal: { apiKey: 'legacy-api-key' },
secretKeys
});
expect(stored.apiKey).toMatchObject({ value: '' });
expect(decryptSecret((stored.apiKey as { secret: string }).secret)).toBe('legacy-api-key');
});
it('supports legacy secret wrappers and clears an edited empty value', () => {
const stored = encryptSystemToolSecrets({
secretsVal: { apiKey: '' },
existingSecretsVal: { apiKey: { value: 'legacy-api-key', secret: '' } },
secretKeys
});
expect(stored).toEqual({});
expect(decryptSystemToolSecrets({ apiKey: { value: 'legacy-api-key', secret: '' } })).toEqual({
apiKey: 'legacy-api-key'
});
});
it('masks only configured secret schema fields', () => {
const masked = maskSystemToolSecrets({
secretsVal: {
apiKey: { secret: encryptSecret('api-key'), value: '' },
region: 'us'
},
secretKeys
});
expect(masked).toEqual({
apiKey: SystemToolSecretMaskedValue,
region: 'us'
});
});
it('derives secret keys from secret input schema entries', () => {
expect(
getSystemToolSecretKeys([
{ key: 'apiKey', label: 'API key', inputType: 'secret' },
{ key: 'region', label: 'Region', inputType: 'select' }
])
).toEqual(new Set(['apiKey']));
});
});
......@@ -104,6 +104,9 @@ export const SecretInputForm = ({
const changeConfigType = (type: SystemToolSecretInputTypeEnum) => {
setValue('type', type);
if (type !== SystemToolSecretInputTypeEnum.manual) {
setValue('value', undefined);
}
onTypeChange?.(type);
};
......@@ -365,7 +368,14 @@ export const SecretInputForm = ({
{t('common:had_auth_value')}
</Box>
</Flex>
<IconButton name="edit" onClick={() => setEditIndex(i)} />
<IconButton
name="edit"
onClick={() => {
setEditIndex(i);
// 进入编辑态即表示替换该密钥,空提交需要清除旧密文。
setValue(`value.${item.key}.secret` as any, '');
}}
/>
</>
)}
</Flex>
......
......@@ -54,6 +54,8 @@ import CopyBox from '@fastgpt/web/components/common/String/CopyBox';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { jsonSchema2SecretInput } from '@fastgpt/global/core/app/jsonschema';
import { useConfirm } from '@fastgpt/web/hooks/useConfirm';
import { SystemToolSecretMaskedValue } from '@fastgpt/global/core/app/tool/systemTool/constants';
import IconButton from '@/pageComponents/account/team/OrgManage/IconButton';
const COST_LIMITS = { max: 1000, min: 0, step: 0.1 };
const FORM_LABEL_WIDTH = '160px';
......@@ -455,6 +457,43 @@ const SystemToolConfigModal = ({
</HStack>
);
if (item.inputType === 'secret' && fieldValue === SystemToolSecretMaskedValue) {
return (
<ConfigRow key={item.key} label={labelSection}>
<Flex alignItems={'center'} gap={2}>
<Flex
flex={1}
borderRadius={'6px'}
border={'0.5px solid'}
borderColor={'primary.200'}
bg={'primary.50'}
h={8}
px={3}
alignItems={'center'}
gap={1}
>
<MyIcon name="checkCircle" w={'16px'} color={'primary.600'} />
<Box fontSize={'sm'} fontWeight={'medium'} color={'primary.600'}>
{t('common:had_auth_value')}
</Box>
</Flex>
{!isToolOffline && (
<IconButton
name="edit"
aria-label={t('common:Edit')}
onClick={() => {
setValue(`secretsVal.${item.key}`, '', {
shouldDirty: true,
shouldValidate: true
});
}}
/>
)}
</Flex>
</ConfigRow>
);
}
if (item.inputType === 'switch') {
return (
<ConfigRow key={item.key} label={labelSection}>
......
......@@ -182,7 +182,7 @@ export const onCreateApp = async ({
}
}
beforeUpdateAppFormat({
await beforeUpdateAppFormat({
nodes: modules
});
if (!AppFolderTypeList.includes(type!)) {
......
......@@ -132,7 +132,7 @@ async function handler(req: ApiRequestProps<UpdateAppBodyType, UpdateAppQueryTyp
const onUpdate = async (session?: ClientSession) => {
// format nodes data
// 1. dataset search limit, less than model quoteMaxToken
beforeUpdateAppFormat({
await beforeUpdateAppFormat({
nodes
});
......
......@@ -41,7 +41,7 @@ async function handler(req: ApiRequestProps<PostPublishAppProps>) {
authToken: true
});
beforeUpdateAppFormat({
await beforeUpdateAppFormat({
nodes
});
if (isPublish) {
......
......@@ -30,7 +30,8 @@ async function handler(
pluginId: toolId,
lang,
source: 'system',
version
version,
maskSecrets: true
});
return AdminSystemToolDetailSchema.parse({
......
......@@ -8,6 +8,13 @@ import {
type UpdateSystemToolBodyType
} from '@fastgpt/global/openapi/core/plugin/admin/tool/api';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import { jsonSchema2SecretInput } from '@fastgpt/global/core/app/jsonschema';
import { SystemToolCodec } from '@fastgpt/global/core/app/tool/systemTool/codec';
import { SystemToolRepo } from '@fastgpt/service/core/app/tool/systemTool/systemTool.repo';
import {
encryptSystemToolSecrets,
getSystemToolSecretKeys
} from '@fastgpt/service/core/app/tool/systemTool/secrets';
export type updateToolQuery = Record<string, never>;
......@@ -20,7 +27,9 @@ const omitUndefinedFields = <T extends Record<string, unknown>>(fields: T) =>
Object.entries(fields).filter(([, value]) => value !== undefined)
) as Partial<T>;
async function handler(
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export async function handler(
req: ApiRequestProps<updateToolBody, updateToolQuery>,
_res: ApiResponseType<any>
): Promise<updateToolResponse> {
......@@ -38,6 +47,24 @@ async function handler(
return Promise.reject('Workflow tool should be updated through app update api');
}
const storedSecretsVal = await (async () => {
if (!('secretsVal' in updateFields) || updateFields.secretsVal === null) {
return updateFields.secretsVal;
}
const toolDetail = await SystemToolRepo.getInstance().getSystemToolDetail({
pluginId,
source: 'system'
});
const inputList = jsonSchema2SecretInput({ jsonSchema: toolDetail.secretSchema });
return encryptSystemToolSecrets({
secretsVal: updateFields.secretsVal,
existingSecretsVal: SystemToolCodec.getConfiguredSecretsVal(plugin),
secretKeys: getSystemToolSecretKeys(inputList)
});
})();
// 基础更新字段
const baseUpdateFields = omitUndefinedFields({
pluginId,
......@@ -51,7 +78,7 @@ async function handler(
});
if ('secretsVal' in updateFields) {
Object.assign(baseUpdateFields, {
secretsVal: updateFields.secretsVal ?? null
secretsVal: storedSecretsVal ?? null
});
}
......@@ -71,6 +98,15 @@ async function handler(
{ upsert: true, session }
);
if ('secretsVal' in updateFields) {
// 工具集的系统密钥只由父工具维护,覆盖历史子工具记录,避免子工具残留旧密钥。
await MongoSystemTool.updateMany(
{ pluginId: { $regex: `^${escapeRegExp(pluginId)}/` } },
{ secretsVal: storedSecretsVal ?? null },
{ session }
);
}
// 如果有子工具,更新子工具
for await (const tool of updateFields.children || []) {
const childPluginId = tool.id.includes('/') ? tool.id : `${pluginId}/${tool.id}`;
......@@ -86,7 +122,7 @@ async function handler(
});
if ('secretsVal' in updateFields) {
Object.assign(childUpdateFields, {
secretsVal: updateFields.secretsVal
secretsVal: storedSecretsVal
});
}
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SystemToolSecretMaskedValue } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { decryptSecret } from '@fastgpt/service/common/secret/aes256gcm';
const mocks = vi.hoisted(() => ({
authSystemAdmin: vi.fn(),
findOne: vi.fn(),
updateOne: vi.fn(),
updateMany: vi.fn(),
mongoSessionRun: vi.fn(),
getSystemToolDetail: vi.fn()
}));
vi.mock('@/service/middleware/entry', () => ({
NextAPI: (handler: any) => handler
}));
vi.mock('@fastgpt/service/support/permission/user/auth', () => ({
authSystemAdmin: mocks.authSystemAdmin
}));
vi.mock('@fastgpt/service/core/plugin/tool/systemToolSchema', () => ({
MongoSystemTool: {
findOne: mocks.findOne,
updateOne: mocks.updateOne,
updateMany: mocks.updateMany
}
}));
vi.mock('@fastgpt/service/common/mongo/sessionRun', () => ({
mongoSessionRun: mocks.mongoSessionRun
}));
vi.mock('@fastgpt/service/core/app/tool/systemTool/systemTool.repo', () => ({
SystemToolRepo: {
getInstance: () => ({
getSystemToolDetail: mocks.getSystemToolDetail
})
}
}));
import { handler } from '@/pages/api/core/plugin/admin/tool/update';
describe('admin system tool update handler', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.authSystemAdmin.mockResolvedValue(undefined);
mocks.findOne.mockResolvedValue(undefined);
mocks.updateOne.mockResolvedValue(undefined);
mocks.updateMany.mockResolvedValue(undefined);
mocks.getSystemToolDetail.mockResolvedValue({
secretSchema: {
type: 'object',
properties: {
apiKey: { type: 'string', isSecret: true }
}
}
});
mocks.mongoSessionRun.mockImplementation((fn: (session: string) => unknown) => fn('session'));
});
it('同步父工具密钥到所有已有子工具,即使请求没有携带 children', async () => {
await handler(
{
body: {
id: 'systemTool-weather',
secretsVal: null
}
} as any,
{} as any
);
expect(mocks.updateMany).toHaveBeenCalledWith(
{ pluginId: { $regex: '^systemTool-weather/' } },
{ secretsVal: null },
{ session: 'session' }
);
expect(mocks.updateOne).toHaveBeenCalledTimes(1);
});
it('为新建的子工具写入与父工具一致的密钥', async () => {
const secretsVal = { apiKey: 'plain-value' };
await handler(
{
body: {
id: 'systemTool-weather',
secretsVal,
children: [{ id: 'forecast', systemKeyCost: 2 }]
}
} as any,
{} as any
);
const storedSecretsVal = mocks.updateOne.mock.calls[0][1].secretsVal;
expect(decryptSecret(storedSecretsVal.apiKey.secret)).toBe('plain-value');
expect(storedSecretsVal.apiKey.value).toBe('');
expect(mocks.updateMany).toHaveBeenCalledWith(
{ pluginId: { $regex: '^systemTool-weather/' } },
{ secretsVal: storedSecretsVal },
{ session: 'session' }
);
expect(mocks.updateOne).toHaveBeenLastCalledWith(
{ pluginId: 'systemTool-weather/forecast' },
expect.objectContaining({
pluginId: 'systemTool-weather/forecast',
secretsVal: storedSecretsVal
}),
{ upsert: true, session: 'session' }
);
});
it('保留管理员详情返回的 masked 系统密钥', async () => {
mocks.findOne.mockResolvedValue({
pluginId: 'systemTool-weather',
secretsVal: { apiKey: 'legacy-value' },
customConfig: {}
});
await handler(
{
body: {
id: 'systemTool-weather',
secretsVal: { apiKey: SystemToolSecretMaskedValue }
}
} as any,
{} as any
);
const storedSecretsVal = mocks.updateOne.mock.calls[0][1].secretsVal;
expect(decryptSecret(storedSecretsVal.apiKey.secret)).toBe('legacy-value');
});
});
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
beforeUpdateAppFormat,
validatePublishAppAgentSkillReadPermissions
......@@ -7,6 +7,7 @@ import {
FlowNodeInputTypeEnum,
FlowNodeTypeEnum
} from '@fastgpt/global/core/workflow/node/constant';
import { SystemToolSecretInputTypeEnum } from '@fastgpt/global/core/app/tool/systemTool/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { StoreNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { MongoAgentSkills } from '@fastgpt/service/core/ai/skill/model/schema';
......@@ -15,8 +16,147 @@ import { getNanoid } from '@fastgpt/global/common/string/tools';
import { getUser } from '@test/datas/users';
import { SkillErrEnum } from '@fastgpt/global/common/error/code/skill';
const mocks = vi.hoisted(() => ({
getClientToolPreviewNode: vi.fn()
}));
vi.mock('@fastgpt/service/core/app/tool/utils/client', () => mocks);
describe('beforeUpdateAppFormat', () => {
it('保存前统一压缩知识库选择项,去掉编辑态删除标记和快照字段', () => {
it.each([SystemToolSecretInputTypeEnum.system, SystemToolSecretInputTypeEnum.team])(
'保存前清理 %s 类型中的临时密钥值',
async (type) => {
const nodes = [
{
inputs: [
{
key: NodeInputKeyEnum.systemInputConfig,
value: {
type,
value: {
apiKey: {
value: 'temporary-secret',
secret: ''
}
}
},
inputList: [{ key: 'apiKey', inputType: 'secret' }]
}
]
} as StoreNodeItemType
];
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual({ type });
}
);
it('即使系统输入被标记为引用,也不能保留临时密钥值', async () => {
const nodes = [
{
inputs: [
{
key: NodeInputKeyEnum.systemInputConfig,
selectedTypeIndex: 0,
renderTypeList: [FlowNodeInputTypeEnum.reference],
value: {
type: SystemToolSecretInputTypeEnum.system,
value: {
apiKey: {
value: 'temporary-secret',
secret: ''
}
}
},
inputList: [{ key: 'apiKey', inputType: 'secret' }]
}
]
} as StoreNodeItemType
];
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual({
type: SystemToolSecretInputTypeEnum.system
});
});
it('保存前仅加密 manual 类型的临时密钥值', async () => {
const nodes = [
{
inputs: [
{
key: NodeInputKeyEnum.systemInputConfig,
value: {
type: SystemToolSecretInputTypeEnum.manual,
value: {
apiKey: {
value: 'temporary-secret',
secret: ''
},
region: 'cn'
}
},
inputList: [
{ key: 'apiKey', inputType: 'secret' },
{ key: 'region', inputType: 'input' }
]
}
]
} as StoreNodeItemType
];
await beforeUpdateAppFormat({ nodes });
const value = nodes[0].inputs[0].value as any;
expect(value.type).toBe(SystemToolSecretInputTypeEnum.manual);
expect(value.value.apiKey.value).toBe('');
expect(value.value.apiKey.secret).toEqual(expect.any(String));
expect(value.value.region).toBe('cn');
});
it('保存 Agent 嵌套工具配置前加密手动密钥', async () => {
mocks.getClientToolPreviewNode.mockResolvedValueOnce({
inputs: [
{
key: NodeInputKeyEnum.systemInputConfig,
inputList: [{ key: 'apiKey', inputType: 'secret' }]
}
]
});
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.agent,
inputs: [
{
key: NodeInputKeyEnum.selectedTools,
value: [
{
id: 'system-tool',
config: {
system_input_config: {
type: SystemToolSecretInputTypeEnum.manual,
value: {
apiKey: { value: 'nested-secret', secret: '' }
}
}
}
}
]
}
]
}
] as StoreNodeItemType[];
await beforeUpdateAppFormat({ nodes });
const config = (nodes[0].inputs[0].value as any)[0].config;
expect(config.system_input_config.value.apiKey.value).toBe('');
expect(config.system_input_config.value.apiKey.secret).toEqual(expect.any(String));
});
it('保存前统一压缩知识库选择项,去掉编辑态删除标记和快照字段', async () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
......@@ -41,7 +181,7 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
......@@ -50,7 +190,7 @@ describe('beforeUpdateAppFormat', () => {
]);
});
it('保存前兼容旧版单对象知识库选择项', () => {
it('保存前兼容旧版单对象知识库选择项', async () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
......@@ -72,7 +212,7 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
......@@ -81,7 +221,7 @@ describe('beforeUpdateAppFormat', () => {
]);
});
it('保存前兼容已压缩的知识库选择项数组', () => {
it('保存前兼容已压缩的知识库选择项数组', async () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
......@@ -103,7 +243,7 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
......@@ -115,7 +255,7 @@ describe('beforeUpdateAppFormat', () => {
]);
});
it('保存前统一压缩 Agent datasetParams 中的知识库选择项', () => {
it('保存前统一压缩 Agent datasetParams 中的知识库选择项', async () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.agent,
......@@ -142,7 +282,7 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toMatchObject({
datasets: [
......@@ -155,7 +295,7 @@ describe('beforeUpdateAppFormat', () => {
});
});
it('保存前保留知识库选择输入的引用模式值', () => {
it('保存前保留知识库选择输入的引用模式值', async () => {
const referenceValue = ['sourceNode', 'datasets'];
const nodes = [
{
......@@ -171,12 +311,12 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toBe(referenceValue);
});
it('保存前遇到非法知识库选择项时抛错,避免清空后继续保存', () => {
it('保存前遇到非法知识库选择项时抛错,避免清空后继续保存', async () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.datasetSearchNode,
......@@ -195,10 +335,10 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
expect(() => beforeUpdateAppFormat({ nodes })).toThrow();
await expect(beforeUpdateAppFormat({ nodes })).rejects.toThrow();
});
it('保存前移除 Agent Skill 的编辑态删除标记和展示快照字段', () => {
it('保存前移除 Agent Skill 的编辑态删除标记和展示快照字段', async () => {
const nodes = [
{
flowNodeType: FlowNodeTypeEnum.agent,
......@@ -227,7 +367,7 @@ describe('beforeUpdateAppFormat', () => {
} as StoreNodeItemType
];
beforeUpdateAppFormat({ nodes });
await beforeUpdateAppFormat({ nodes });
expect(nodes[0].inputs[0].value).toEqual([
{
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment