refactor(credentials): introduce /credentials subresource pattern

Replace the legacy "redacted placeholder + Clear* boolean" pattern with
dedicated per-resource credential subresources across MCP services,
Models, Web search providers, and Data sources.

Why
---
The previous design had three problems:

1. Main PUT body carried secret fields. The frontend echoed back a
   redacted "***" placeholder, and a fragile MergeUpdate / IsRedactedOrEmpty
   defense in the service layer tried to detect "user did not change this"
   vs "user wants to clear this". A regression in that defense (or a new
   frontend forgetting it) silently overwrites the stored secret with the
   placeholder.

2. The "remove this credential" UX was a red checkbox under a pre-filled
   password input. Three intents (preserve / replace / clear) collapsed
   onto one field, and credential changes were bundled with unrelated
   config edits in the same submit. Users wiped working keys by mistake.

3. Secret presence was inferred from "did the response come back with a
   '***' placeholder", which couples the contract to a magic string.

Design
------
Each of the four resources now exposes:

  PUT    /{resource}/{id}/credentials          # write one or more fields
  DELETE /{resource}/{id}/credentials/{field}  # clear a single field

"Is this configured?" metadata travels on the main resource response as
a typed map (dto.MCPServiceResponse.Credentials etc.) — no separate GET
endpoint. The frontend reads the configured boolean from the main GET
and never sees secret values at all.

Main PUT endpoints now ignore any secret fields in the request body and
log a deprecation warning if they appear, so legacy clients fail loudly
rather than overwriting silently.

Frontend
--------
- New reusable <CredentialResource> component renders a three-state card
  (unconfigured / configured / editing) and drives the dedicated
  endpoints. Used by MCP, Model, Web search; DataSource has a bespoke
  card with the same behaviour because its credentials are a single
  atomic per-connector map.
- Cancel from edit mode now restores state synchronously from the meta
  prop. The previous async refresh() was a no-op while state was still
  'editing', leaving the input frozen open.
- Remove is single-click + toast. The danger-themed button is the
  deterrent; a modal confirm adds friction without adding safety (the
  plaintext is irrecoverable client-side either way — recovery means
  re-typing).

DTOs (internal/handler/dto/) are deliberately separate from the GORM
models so "no secret in response" is a compile-time invariant: a future
contributor cannot leak a secret without explicitly adding the field to
the DTO, which is review-able in a single diff.

Storage is unchanged — credentials still live in the existing jsonb /
parameters columns. No schema migration.

Cleanup
-------
- types.MCPService / types.Model / types.WebSearchProviderEntity /
  types.DataSourceConfig: drop ClearAPIKey / ClearToken / ClearAppSecret
  / ClearCredentials boolean fields, MergeUpdate(), RedactSensitiveData().
- utils/types/secret.go: drop PreserveIfRedacted / IsRedactedOrEmpty.
  RedactedSecretPlaceholder constant is retained because VectorStore
  still uses the old pattern and is out of scope here.
- Frontend hasExistingApiKey / clearApiKey / convertToLegacyFormat
  redaction handling removed; i18n keys renamed secret -> credential.
This commit is contained in:
wizardchen
2026-05-17 15:09:30 +08:00
committed by lyingbug
parent f30e9ccd0f
commit 7643dd457e
53 changed files with 3271 additions and 2347 deletions

View File

@@ -37,17 +37,15 @@ type MCPService struct {
// MCPAuthConfig represents authentication configuration for MCP service.
//
// ClearAPIKey and ClearToken are write-only flags used in Update requests to
// explicitly remove a stored credential. The server never returns these
// fields in responses.
// Secret fields (APIKey, Token) are accepted on create but are never returned
// by the server. To mutate credentials on an existing service, use the
// dedicated /credentials subresource — see the MCP credentials API for the
// PUT / DELETE shape. Sending secret fields in a main PUT body is silently
// ignored server-side.
type MCPAuthConfig struct {
APIKey string `json:"api_key,omitempty"`
Token string `json:"token,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Write-only clear flags.
ClearAPIKey bool `json:"clear_api_key,omitempty"`
ClearToken bool `json:"clear_token,omitempty"`
}
// MCPAdvancedConfig represents advanced configuration for MCP service

View File

@@ -17,6 +17,9 @@ export interface DataSource {
last_sync_at: string | null
last_sync_result: any
error_message: string
// Single-field "credentials" map from the main response — DataSource
// credentials are a per-connector atomic set.
credentials?: { credentials: { configured: boolean } }
created_at: string
updated_at: string
latest_sync_log?: SyncLog
@@ -111,3 +114,27 @@ export function resumeDataSource(id: string) {
export function getSyncLogs(id: string, limit = 20, offset = 0) {
return get(`/api/v1/datasource/${id}/logs?limit=${limit}&offset=${offset}`)
}
// ----------------------------------------------------------------------------
// Data source credential subresource. Unlike the other three resources,
// DataSource exposes a single logical field "credentials" because connector
// auth is a per-connector atomic map. See internal/handler/dto/datasource.go.
// ----------------------------------------------------------------------------
export interface DataSourceCredentialsResponse {
fields: {
credentials: { configured: boolean }
}
}
export async function putDataSourceCredentials(
id: string,
credentials: Record<string, unknown>,
): Promise<DataSourceCredentialsResponse> {
const response: any = await put(`/api/v1/datasource/${id}/credentials`, { credentials })
return (response.data ?? response) as DataSourceCredentialsResponse
}
export async function deleteDataSourceCredentials(id: string): Promise<void> {
await del(`/api/v1/datasource/${id}/credentials/credentials`)
}

View File

@@ -10,13 +10,13 @@ export interface MCPService {
url?: string // Optional: required for SSE/HTTP Streamable
headers?: Record<string, string>
auth_config?: {
// Secret fields (api_key, token) are NEVER returned by the server in
// this shape — they live behind the /credentials subresource. The
// optional-property typing remains so create-mode payloads can still
// carry them in the initial POST body.
api_key?: string
token?: string
custom_headers?: Record<string, string>
// Write-only flags — set to true in Update requests to explicitly remove
// the corresponding credential. The server never returns these fields.
clear_api_key?: boolean
clear_token?: boolean
}
advanced_config?: {
timeout?: number
@@ -29,6 +29,10 @@ export interface MCPService {
}
env_vars?: Record<string, string> // Environment variables for stdio transport
is_builtin?: boolean // Whether this is a builtin MCP service
// Per-field "configured?" map embedded on the main response (server-side
// dto.MCPServiceResponse.Credentials). Drives the CredentialResource card
// without a follow-up GET. Absent for builtin services.
credentials?: Record<McpCredentialField, CredentialFieldMetadata>
created_at?: string
updated_at?: string
}
@@ -127,6 +131,41 @@ export async function setMCPToolApproval(serviceId: string, toolName: string, re
})
}
// ----------------------------------------------------------------------------
// Credential subresource (issue #988 follow-up).
//
// Secrets travel through a dedicated /credentials endpoint instead of the
// main MCP PUT body. "Is this configured?" metadata is embedded on the main
// MCPService response (MCPService.credentials), so there is no GET on this
// endpoint — only PUT (write) and DELETE (clear). Both trigger an MCP
// client reconnect server-side.
// ----------------------------------------------------------------------------
export type McpCredentialField = 'api_key' | 'token'
export interface CredentialFieldMetadata {
configured: boolean
}
export interface McpCredentialsResponse {
fields: Record<McpCredentialField, CredentialFieldMetadata>
}
export async function putMCPCredentials(
serviceId: string,
body: Partial<Record<McpCredentialField, string>>
): Promise<McpCredentialsResponse> {
const response: any = await put(`/api/v1/mcp-services/${serviceId}/credentials`, body)
return (response.data ?? response) as McpCredentialsResponse
}
export async function deleteMCPCredentialField(
serviceId: string,
field: McpCredentialField
): Promise<void> {
await del(`/api/v1/mcp-services/${serviceId}/credentials/${field}`)
}
export async function resolveToolApproval(
pendingId: string,
body: { decision: 'approve' | 'reject'; modified_args?: Record<string, unknown>; reason?: string }

View File

@@ -27,15 +27,18 @@ export interface ModelConfig {
custom_headers?: Record<string, string>;
supports_vision?: boolean; // Whether the model accepts image/multimodal input
app_id?: string;
// Secret fields (api_key, app_secret) are never returned by the server in
// this shape — they live behind the /credentials subresource. They are
// kept on the type so create-mode payloads can still carry them in the
// initial POST body.
app_secret?: string;
// Write-only flags — set to true in Update requests to explicitly remove
// the corresponding credential. The server never returns these fields.
clear_api_key?: boolean;
clear_app_secret?: boolean;
};
is_default?: boolean;
is_builtin?: boolean;
status?: string;
// Per-field configured? metadata from the main response. Absent for
// builtin models.
credentials?: Record<ModelCredentialField, { configured: boolean }>;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
@@ -135,6 +138,32 @@ export function deleteModel(id: string): Promise<void> {
});
}
// ----------------------------------------------------------------------------
// Model credential subresource. See mcp-service.ts for the matching MCP API
// shape and the design notes in internal/handler/dto/mcp.go.
// ----------------------------------------------------------------------------
export type ModelCredentialField = 'api_key' | 'app_secret'
export interface ModelCredentialsResponse {
fields: Record<ModelCredentialField, { configured: boolean }>
}
export async function putModelCredentials(
id: string,
body: Partial<Record<ModelCredentialField, string>>,
): Promise<ModelCredentialsResponse> {
const response: any = await put(`/api/v1/models/${id}/credentials`, body)
return (response.data ?? response) as ModelCredentialsResponse
}
export async function deleteModelCredentialField(
id: string,
field: ModelCredentialField,
): Promise<void> {
await del(`/api/v1/models/${id}/credentials/${field}`)
}
export interface InitializeWeKnoraCloudRequest {
app_id: string
app_secret: string

View File

@@ -8,16 +8,18 @@ export interface WebSearchProviderEntity {
provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu' | 'searxng'
description?: string
parameters: {
// api_key is never returned by the server in this shape; it lives behind
// the /credentials subresource. Kept on the type so the initial create
// POST can still include it.
api_key?: string
engine_id?: string
base_url?: string
proxy_url?: string
extra_config?: Record<string, string>
// Write-only flag — set to true in Update requests to explicitly remove
// the stored API key. The server never returns this field.
clear_api_key?: boolean
}
is_default?: boolean
// Per-field configured? metadata from the main response.
credentials?: Record<WebSearchCredentialField, { configured: boolean }>
created_at?: string
updated_at?: string
}
@@ -69,6 +71,31 @@ export function listWebSearchProviderTypes(): Promise<WebSearchProviderTypeInfo[
})
}
// ----------------------------------------------------------------------------
// Web search provider credential subresource.
// ----------------------------------------------------------------------------
export type WebSearchCredentialField = 'api_key'
export interface WebSearchCredentialsResponse {
fields: Record<WebSearchCredentialField, { configured: boolean }>
}
export async function putWebSearchProviderCredentials(
id: string,
body: Partial<Record<WebSearchCredentialField, string>>,
): Promise<WebSearchCredentialsResponse> {
const response: any = await put(`/api/v1/web-search-providers/${id}/credentials`, body)
return (response.data ?? response) as WebSearchCredentialsResponse
}
export async function deleteWebSearchProviderCredentialField(
id: string,
field: WebSearchCredentialField,
): Promise<void> {
await del(`/api/v1/web-search-providers/${id}/credentials/${field}`)
}
// Test a web search provider connection.
// If id is provided, tests the existing saved provider.
// If data is provided, tests with raw credentials (no persistence).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
<!--
CredentialResource per-field "configured / unconfigured / editing" card.
Why this component exists:
The previous UX (PR #990) showed a password input pre-filled with a redacted
placeholder plus a red "Remove this credential" checkbox below it. That
conflated three distinct user intents (preserve / replace / clear) into a
single form field, and bundled credential changes with unrelated config
edits in the same submit. Users could accidentally wipe a working key by
toggling the wrong checkbox.
This component splits credentials out as an independent resource:
- Read-only "configured" badge by default, derived from the `meta` prop.
- "Replace" expands an input + Save/Cancel; commit is an explicit
PUT to the credential subresource (no main-form submit needed).
- "Remove" pops a danger-themed confirmation and on confirm calls DELETE
immediately. No "save form to apply" intermediate state.
`meta` is the source of truth and comes from the parent resource's main
GET response (`<resource>.credentials` on every DTO) — there is no
dedicated GET /credentials endpoint. After a successful save/remove the
component derives the new local state from the save's return value (or,
for remove, by setting the field to unconfigured) and emits 'changed' so
the parent can re-fetch the main resource if it cares about anything
else that depends on credential state.
-->
<template>
<div class="credential-resource">
<div v-for="field in fields" :key="field.key" class="credential-row">
<!-- Configured: read-only badge + actions -->
<template v-if="stateOf(field.key) === 'configured'">
<div class="credential-summary">
<t-icon name="check-circle-filled" class="status-icon success" />
<div class="credential-meta">
<div class="credential-label">{{ field.label }}</div>
<div class="credential-sub">{{ t('credential.configured') }}</div>
</div>
<div class="credential-actions">
<t-button size="small" variant="outline" @click="enterEdit(field.key)">
{{ t('credential.update') }}
</t-button>
<t-button size="small" variant="outline" theme="danger" :loading="busy[field.key] === 'remove'"
@click="onRemove(field)">
{{ t('credential.remove') }}
</t-button>
</div>
</div>
</template>
<!-- Unconfigured: collapsed prompt + "Configure" -->
<template v-else-if="stateOf(field.key) === 'unconfigured'">
<div class="credential-summary">
<t-icon name="info-circle" class="status-icon muted" />
<div class="credential-meta">
<div class="credential-label">{{ field.label }}</div>
<div class="credential-sub">{{ t('credential.unconfigured') }}</div>
</div>
<div class="credential-actions">
<t-button size="small" variant="outline" @click="enterEdit(field.key)">
{{ t('credential.configure') }}
</t-button>
</div>
</div>
</template>
<!-- Editing: inline input + Save / Cancel -->
<template v-else>
<div class="credential-edit">
<div class="credential-label">{{ field.label }}</div>
<t-input v-model="drafts[field.key]" type="password"
:placeholder="field.placeholder ?? t('credential.inputPlaceholder')" :autocomplete="'new-password'"
@keydown.enter.prevent="onSave(field)" />
<div class="credential-edit-actions">
<t-button size="small" variant="outline" @click="cancelEdit(field.key)">
{{ t('common.cancel') }}
</t-button>
<t-button size="small" theme="primary" :loading="busy[field.key] === 'save'" :disabled="!drafts[field.key]"
@click="onSave(field)">
{{ t('common.save') }}
</t-button>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts" generic="K extends string">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
export interface CredentialFieldDef<K extends string = string> {
key: K
label: string
// Optional connector-specific placeholder shown only when the input is
// visible (e.g. "ntn_xxxx" for Notion). Defaults to a generic "Enter value".
placeholder?: string
}
export interface CredentialResourceApi<K extends string = string> {
// PUT /credentials — body keyed by field name, value is the new secret.
// Returns the updated per-field configured map.
save: (patch: Partial<Record<K, string>>) => Promise<Record<K, { configured: boolean }>>
// DELETE /credentials/:field
remove: (field: K) => Promise<void>
}
interface Props {
fields: CredentialFieldDef<K>[]
api: CredentialResourceApi<K>
// Initial per-field "configured?" map, sourced from the parent resource's
// main GET response. The component reads it on first render and after
// every reset; subsequent state transitions are tracked locally.
meta: Record<K, { configured: boolean }>
}
interface Emits {
// Fires after every successful save or remove so the parent can refresh
// any derived view (e.g. badges that depend on credential state) or just
// reload the main resource to keep `meta` in sync.
(e: 'changed'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
type State = 'configured' | 'unconfigured' | 'editing'
// Local view state per field. Source of truth is props.meta, but we track
// the editing state and any locally-applied save/remove transitions here so
// the UI doesn't snap back when the parent re-renders before re-fetching.
const states = reactive<Record<string, State>>({})
const drafts = reactive<Record<string, string>>({})
const busy = reactive<Record<string, 'save' | 'remove' | null>>({})
function deriveStatesFromMeta(meta: Record<string, { configured: boolean }>) {
for (const f of props.fields) {
// Preserve in-progress edits across parent re-renders — `meta` describes
// server state, the editing flag is user intent.
if (states[f.key] === 'editing') continue
states[f.key] = meta[f.key]?.configured ? 'configured' : 'unconfigured'
}
}
// Initialize from the first meta snapshot, and re-derive whenever the parent
// passes a new one (after a main-resource refresh). watch with immediate:true
// covers both cases in one place.
watch(
() => props.meta,
(m) => deriveStatesFromMeta(m ?? ({} as Record<K, { configured: boolean }>)),
{ immediate: true, deep: true },
)
// If the parent swaps the api (e.g. user opens a different resource), drop
// transient state. props.meta will follow and re-init via the watch above.
watch(() => props.api, () => {
for (const k of Object.keys(states)) delete states[k]
for (const k of Object.keys(drafts)) delete drafts[k]
})
function stateOf(key: string): State {
return states[key] ?? 'unconfigured'
}
function enterEdit(key: string) {
drafts[key] = ''
states[key] = 'editing'
}
// Cancel returns directly to whatever the parent told us via props.meta —
// no async re-fetch needed, and no risk of staying stuck in 'editing'
// because the previous implementation's refresh was a no-op when state
// was already 'editing'.
function cancelEdit(key: string) {
drafts[key] = ''
states[key] = props.meta?.[key as K]?.configured ? 'configured' : 'unconfigured'
}
async function onSave(field: CredentialFieldDef) {
const value = drafts[field.key]
if (!value) return
busy[field.key] = 'save'
try {
// Apply the save's returned metadata locally so the card flips to
// 'configured' immediately. Skip the editing-preserve guard since this
// particular field just finished editing.
const updated = await props.api.save({ [field.key]: value } as Partial<Record<K, string>>)
for (const f of props.fields) {
if (f.key === field.key) continue
if (states[f.key] === 'editing') continue
states[f.key] = updated[f.key as K]?.configured ? 'configured' : 'unconfigured'
}
states[field.key] = updated[field.key as K]?.configured ? 'configured' : 'unconfigured'
drafts[field.key] = ''
MessagePlugin.success(t('credential.savedToast'))
emit('changed')
} catch (err: any) {
MessagePlugin.error(err?.message || t('credential.saveFailed'))
} finally {
busy[field.key] = null
}
}
// Remove is a single-click action: skip the modal confirm dialog and just
// do it, with a toast for feedback. Rationale: the secret is irrecoverable
// from the client side regardless of whether we confirm (we never had the
// plaintext), so a modal adds friction without adding safety — re-typing
// the secret is the recovery path either way. The danger-themed "Remove"
// button itself already serves as a visual deterrent against misclicks.
async function onRemove(field: CredentialFieldDef) {
busy[field.key] = 'remove'
try {
await props.api.remove(field.key as K)
states[field.key] = 'unconfigured'
MessagePlugin.success(t('credential.removedToast'))
emit('changed')
} catch (err: any) {
MessagePlugin.error(err?.message || t('credential.removeFailed'))
} finally {
busy[field.key] = null
}
}
</script>
<style scoped lang="less">
.credential-resource {
display: flex;
flex-direction: column;
gap: 12px;
}
.credential-row {
border: 1px solid var(--td-component-stroke);
border-radius: 6px;
padding: 12px 14px;
background: var(--td-bg-color-container);
}
.credential-summary {
display: flex;
align-items: center;
gap: 12px;
}
.credential-meta {
flex: 1;
min-width: 0;
}
.credential-label {
font-size: 14px;
font-weight: 500;
color: var(--td-text-color-primary);
margin-bottom: 2px;
}
.credential-sub {
font-size: 12px;
color: var(--td-text-color-secondary);
}
.credential-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.status-icon {
font-size: 18px;
flex-shrink: 0;
&.success {
color: var(--td-success-color);
}
&.muted {
color: var(--td-text-color-placeholder);
}
}
.credential-edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.credential-edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

View File

@@ -4119,6 +4119,7 @@ export default {
connected: 'Connected',
connectionFailed: 'Connection failed',
isRequired: 'is required',
credentialsLabel: 'credentials',
resourceHint: 'Select the spaces or folders to sync',
untitled: 'Untitled',
resourceLoadFailed: 'Failed to load resources',
@@ -4284,15 +4285,22 @@ export default {
createdAt: 'Created at',
},
},
secret: {
// Placeholder shown inside a credential input when a value is currently
// stored server-side. The bullet run doubles as a visual "something is
// there" cue (matching how the password type renders dots); the
// parenthetical tells the user what typing will do.
storedPlaceholder: '•••••••• (Enter new value to replace)',
clearHint: 'Remove this credential',
confirmClearTitle: 'Confirm credential removal',
confirmClearBody:
'This permanently deletes the stored credential. Integrations using it will stop working. Continue?',
// Shared credential resource UI (CredentialResource.vue). Keep keys
// generic so the same component can drive MCP / Model / WebSearch /
// DataSource credential surfaces without per-resource overrides.
credential: {
configured: 'Configured',
unconfigured: 'Not configured',
configure: 'Configure',
update: 'Replace',
remove: 'Remove',
inputPlaceholder: 'Enter value',
savedToast: 'Credential saved',
saveFailed: 'Failed to save credential',
removedToast: 'Credential removed',
removeFailed: 'Failed to remove credential',
confirmRemoveTitle: 'Remove {field}?',
confirmRemoveBody:
'This permanently deletes the stored credential. Integrations using it will stop working until you configure a new value.',
},
}

View File

@@ -4183,6 +4183,7 @@ export default {
connected: "연결됨",
connectionFailed: "연결 실패",
isRequired: "은(는) 필수입니다",
credentialsLabel: "자격 증명",
resourceHint: "동기화할 공간/폴더를 선택하세요",
untitled: "제목 없음",
resourceLoadFailed: "리소스 목록 로드 실패",
@@ -4347,11 +4348,19 @@ export default {
createdAt: "생성 시각",
},
},
secret: {
storedPlaceholder: "•••••••• (새 값을 입력하면 교체됩니다)",
clearHint: "이 credential 제거",
confirmClearTitle: "credential 제거 확인",
confirmClearBody:
"저장된 credential이 영구 삭제되며, 이를 사용하는 통합이 중단됩니다. 계속할까요?",
credential: {
configured: "구성됨",
unconfigured: "구성되지 않음",
configure: "구성",
update: "교체",
remove: "제거",
inputPlaceholder: "값을 입력하세요",
savedToast: "credential이 저장되었습니다",
saveFailed: "credential 저장 실패",
removedToast: "credential이 제거되었습니다",
removeFailed: "credential 제거 실패",
confirmRemoveTitle: "{field} 제거하시겠습니까?",
confirmRemoveBody:
"저장된 credential이 영구적으로 삭제되며, 이를 사용하는 통합은 새 값을 구성할 때까지 동작하지 않습니다.",
},
};

View File

@@ -4083,6 +4083,7 @@ export default {
connected: 'Подключено',
connectionFailed: 'Подключение не удалось',
isRequired: 'обязательно для заполнения',
credentialsLabel: 'учётные данные',
resourceHint: 'Выберите пространства или папки для синхронизации',
untitled: 'Без названия',
resourceLoadFailed: 'Не удалось загрузить список ресурсов',
@@ -4247,11 +4248,19 @@ export default {
createdAt: 'Создано',
},
},
secret: {
storedPlaceholder: '•••••••• (Введите новое значение для замены)',
clearHint: 'Удалить эти учётные данные',
confirmClearTitle: 'Подтверждение удаления учётных данных',
confirmClearBody:
'Это действие безвозвратно удалит сохранённые учётные данные. Интеграции, использующие их, перестанут работать. Продолжить?',
credential: {
configured: 'Настроено',
unconfigured: 'Не настроено',
configure: 'Настроить',
update: 'Заменить',
remove: 'Удалить',
inputPlaceholder: 'Введите значение',
savedToast: 'Учётные данные сохранены',
saveFailed: 'Не удалось сохранить учётные данные',
removedToast: 'Учётные данные удалены',
removeFailed: 'Не удалось удалить учётные данные',
confirmRemoveTitle: 'Удалить {field}?',
confirmRemoveBody:
'Сохранённые учётные данные будут безвозвратно удалены. Интеграции, использующие их, перестанут работать до настройки нового значения.',
},
}

View File

@@ -4115,6 +4115,7 @@ export default {
connected: "已连接",
connectionFailed: "连接失败",
isRequired: "为必填项",
credentialsLabel: "凭证",
resourceHint: "选择要同步的内容空间/文件夹",
untitled: "无标题",
resourceLoadFailed: "加载资源列表失败",
@@ -4280,11 +4281,19 @@ export default {
createdAt: "创建时间",
},
},
secret: {
storedPlaceholder: "•••••••• (输入新值以替换)",
clearHint: "删除此凭据",
confirmClearTitle: "确认删除凭据",
confirmClearBody:
"此操作将永久删除已保存的凭据,依赖它的集成将停止工作。是否继续?",
credential: {
configured: "已配置",
unconfigured: "未配置",
configure: "配置",
update: "更换",
remove: "移除",
inputPlaceholder: "请输入",
savedToast: "凭据已保存",
saveFailed: "保存凭据失败",
removedToast: "凭据已移除",
removeFailed: "移除凭据失败",
confirmRemoveTitle: "移除 {field}",
confirmRemoveBody:
"此操作将永久删除已保存的凭据,依赖它的集成将停止工作,直到您重新配置。",
},
};

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import {
createDataSource,
@@ -10,6 +10,8 @@ import {
validateCredentials,
listResources,
deleteDataSource,
putDataSourceCredentials,
deleteDataSourceCredentials,
type DataSource,
type Resource,
} from '@/api/datasource'
@@ -28,37 +30,56 @@ const isEdit = computed(() => !!props.dataSource)
const step = ref(0)
const submitting = ref(false)
// Fixed placeholder returned by the server for redacted credential strings.
// Must match internal/types/secret.go → RedactedSecretPlaceholder.
const REDACTED_PLACEHOLDER = '***'
// In edit mode the credential "configured?" flag travels on the main
// DataSource response (DataSource.credentials.credentials.configured —
// server-side dto.DataSourceResponse.Credentials). True iff a credential
// map is currently stored server-side.
const credentialsConfigured = ref(false)
// Per-field: is this specific credential key stored server-side? Drives the
// per-input placeholder swap to the "••••••• (Enter new value to replace)"
// hint when the server marked it present.
function hasFieldCredential(key: string): boolean {
return props.dataSource?.config?.credentials?.[key] === REDACTED_PLACEHOLDER
}
// "Replace credentials" mode toggle in edit. Defaults to false: a configured
// connector shows a small "Credentials configured ✓" line with Replace /
// Remove actions. Toggling Replace reveals the credential inputs so the
// user can type a new set. Untoggling discards anything typed.
const replaceCredentialsMode = ref(false)
// Connector-level rollup: true when any credential key is stored. Used to
// decide whether to render the "Remove all credentials" checkbox.
const hasExistingCredentials = computed(() => {
const creds = props.dataSource?.config?.credentials
if (!creds) return false
return Object.values(creds).some(v => v === REDACTED_PLACEHOLDER)
// Whether the credential input section is interactive right now. In create
// mode it's always shown; in edit mode only when the user opted in to
// Replace, OR when nothing is configured yet (degenerate case where the
// data source row exists with no credentials stored).
const credentialsInputVisible = computed(() => {
if (!isEdit.value) return true
if (!credentialsConfigured.value) return true
return replaceCredentialsMode.value
})
// Placeholder for a given credential input: stored → shared hint,
// otherwise the connector-specific default (e.g. "ntn_xxxx" for Notion).
function credentialPlaceholder(field: { key: string; placeholder: string }): string {
return hasFieldCredential(field.key)
? t('secret.storedPlaceholder')
: field.placeholder
function refreshCredentialsStatus() {
// Re-derive from whatever the parent passed in props.dataSource. Called
// when the dialog opens or props.dataSource is swapped; the parent is
// expected to re-fetch the data source list after credential mutations
// so the new metadata flows in here automatically.
if (!isEdit.value || !props.dataSource) {
credentialsConfigured.value = false
return
}
credentialsConfigured.value =
props.dataSource.credentials?.credentials?.configured === true
}
// Explicit "remove all credentials" flag. Because credentials form an atomic
// set per connector (e.g. an OAuth pair) we only support all-or-nothing
// clearing, mirroring the backend DataSourceConfig.ClearCredentials contract.
const clearCredentials = ref(false)
// Single-click remove with toast feedback. Mirrors the CredentialResource
// component's UX: the secret is irrecoverable client-side either way, so a
// modal confirm just adds friction. The danger-themed button is the deterrent.
async function removeCredentials() {
if (!props.dataSource?.id) return
try {
await deleteDataSourceCredentials(props.dataSource.id)
credentialsConfigured.value = false
replaceCredentialsMode.value = false
form.value.config.credentials = {}
MessagePlugin.success(t('credential.removedToast'))
} catch (e: any) {
MessagePlugin.error(e?.message || t('credential.removeFailed'))
}
}
// Form data
const form = ref({
@@ -245,14 +266,12 @@ watch(visible, (v) => {
selectedResourceIds.value = []
if (isEdit.value && props.dataSource) {
// Reset clear intent on every open so an aborted deletion doesn't
// carry over to the next edit session.
clearCredentials.value = false
// Credentials are intentionally NOT pre-filled from the server response
// — even though the server returns '***' for stored secrets, the form
// holds an empty map. This keeps the "non-empty means user typed it"
// invariant that the save path relies on, and the badge reflects the
// stored state via hasExistingCredentials instead.
// Reset edit/replace toggle every open so an aborted replace doesn't
// carry over. credentialsConfigured will be refreshed from the
// /credentials subresource (run separately below).
replaceCredentialsMode.value = false
credentialsConfigured.value = false
refreshCredentialsStatus()
form.value = {
name: props.dataSource.name,
type: props.dataSource.type,
@@ -269,7 +288,8 @@ watch(visible, (v) => {
selectedResourceIds.value = form.value.config?.resource_ids || []
tempDsId.value = props.dataSource.id
} else {
clearCredentials.value = false
replaceCredentialsMode.value = false
credentialsConfigured.value = false
form.value = {
name: '',
type: '',
@@ -450,51 +470,60 @@ function prevStep() {
step.value--
}
// Prompt the user before irrevocable credential removal.
function confirmClearIfNeeded(): Promise<boolean> {
if (!clearCredentials.value) return Promise.resolve(true)
return new Promise((resolve) => {
const d = DialogPlugin.confirm({
header: t('secret.confirmClearTitle'),
body: t('secret.confirmClearBody'),
confirmBtn: { content: t('common.confirm'), theme: 'danger' },
cancelBtn: t('common.cancel'),
onConfirm: () => { d.hide(); resolve(true) },
onCancel: () => { d.hide(); resolve(false) },
onClose: () => { d.hide(); resolve(false) },
})
})
}
// Build the config payload for Create / Update requests. In edit mode this
// applies write-only secret semantics:
// - clearCredentials checked → send clear_credentials: true (backend wipes)
// - user typed credential values → send them as-is
// - user left fields blank → send empty credentials map (backend preserves
// every existing credential key individually)
// Build the config payload for Create / Update requests.
//
// Create mode: credentials flow inline so the initial data source row
// already carries them.
//
// Edit mode: credentials NEVER flow through the main PUT — they go via the
// /credentials subresource, committed before the main submit (see
// commitCredentialsIfNeeded). Sending an empty map keeps the backend
// validator happy.
function buildConfigPayload(): Record<string, unknown> {
const cfg: Record<string, unknown> = {
credentials: clearCredentials.value ? {} : { ...form.value.config.credentials },
return {
credentials: isEdit.value ? {} : { ...form.value.config.credentials },
resource_ids: form.value.config.resource_ids,
settings: form.value.config.settings,
}
if (clearCredentials.value) {
cfg.clear_credentials = true
}
// In edit mode, when the user opted in to Replace credentials and typed at
// least one value, commit it to /credentials before the main PUT. Aborts
// the whole submit on failure so we don't leave the row partially saved.
async function commitCredentialsIfNeeded(dsId: string): Promise<boolean> {
if (!isEdit.value || !replaceCredentialsMode.value) return true
const filled = Object.entries(form.value.config.credentials).filter(
([, v]) => typeof v === 'string' ? v !== '' : v != null,
)
if (filled.length === 0) return true
try {
await putDataSourceCredentials(dsId, Object.fromEntries(filled))
credentialsConfigured.value = true
replaceCredentialsMode.value = false
form.value.config.credentials = {}
return true
} catch (e: any) {
MessagePlugin.error(e?.message || e?.error || t('credential.saveFailed'))
return false
}
return cfg
}
// --- Final submit ---
async function handleSubmit() {
const ok = await confirmClearIfNeeded()
if (!ok) return
form.value.config.resource_ids = selectedResourceIds.value
submitting.value = true
try {
let dataSourceId = tempDsId.value
if (tempDsId.value) {
// Commit credential replacement BEFORE the main PUT so a validation
// failure on credentials doesn't leave us with an updated row that
// still points at the old broken token.
const credsOk = await commitCredentialsIfNeeded(tempDsId.value)
if (!credsOk) {
submitting.value = false
return
}
await updateDataSource(tempDsId.value, {
...form.value,
config: buildConfigPayload(),
@@ -565,21 +594,11 @@ const stepTitles = computed(() => [
</script>
<template>
<t-dialog
v-model:visible="visible"
:header="isEdit ? t('datasource.editTitle') : t('datasource.createTitle')"
:footer="false"
width="640px"
destroy-on-close
:on-close="handleClose"
>
<t-dialog v-model:visible="visible" :header="isEdit ? t('datasource.editTitle') : t('datasource.createTitle')"
:footer="false" width="640px" destroy-on-close :on-close="handleClose">
<!-- Step indicator -->
<div class="ds-steps">
<div
v-for="(title, i) in stepTitles"
:key="i"
:class="['ds-step', { active: step === i, done: step > i }]"
>
<div v-for="(title, i) in stepTitles" :key="i" :class="['ds-step', { active: step === i, done: step > i }]">
<span class="ds-step-num">{{ step > i ? '&#10003;' : i + 1 }}</span>
<span class="ds-step-title">{{ title }}</span>
</div>
@@ -588,12 +607,8 @@ const stepTitles = computed(() => [
<!-- Step 0: Select connector type -->
<div v-if="step === 0" class="ds-step-content">
<div class="ds-type-grid">
<div
v-for="def in connectorDefs"
:key="def.type"
:class="['ds-type-card', { disabled: !def.available }]"
@click="selectType(def)"
>
<div v-for="def in connectorDefs" :key="def.type" :class="['ds-type-card', { disabled: !def.available }]"
@click="selectType(def)">
<div class="ds-type-header">
<DataSourceTypeIcon :type="def.type" :size="20" />
<span class="ds-type-name">{{ t(`datasource.connector.${def.type}`) }}</span>
@@ -607,7 +622,8 @@ const stepTitles = computed(() => [
<!-- Step 1: Credentials -->
<div v-if="step === 1" class="ds-step-content">
<!-- Compact collapsible prereq hint -->
<div v-if="currentDef && currentDef.requiredPermissions.length > 0" class="ds-prereq-bar" @click="prereqExpanded = !prereqExpanded">
<div v-if="currentDef && currentDef.requiredPermissions.length > 0" class="ds-prereq-bar"
@click="prereqExpanded = !prereqExpanded">
<t-icon name="help-circle" size="14px" />
<span>{{ t(`datasource.prereqBarText_${form.type}`, t('datasource.prereqBarText')) }}</span>
<t-icon :name="prereqExpanded ? 'chevron-up' : 'chevron-down'" size="14px" class="ds-prereq-arrow" />
@@ -616,14 +632,17 @@ const stepTitles = computed(() => [
<div class="ds-prereq-item">
<span class="ds-prereq-num">1</span>
<div>
<div class="ds-prereq-item-title">{{ t(`datasource.prereqStep1Brief_${form.type}`, t('datasource.prereqBotBrief')) }}</div>
<div class="ds-prereq-item-desc">{{ t(`datasource.prereqStep1Desc_${form.type}`, t('datasource.prereqBotDesc')) }}</div>
<div class="ds-prereq-item-title">{{ t(`datasource.prereqStep1Brief_${form.type}`,
t('datasource.prereqBotBrief')) }}</div>
<div class="ds-prereq-item-desc">{{ t(`datasource.prereqStep1Desc_${form.type}`,
t('datasource.prereqBotDesc')) }}</div>
</div>
</div>
<div class="ds-prereq-item">
<span class="ds-prereq-num">2</span>
<div>
<div class="ds-prereq-item-title">{{ t(`datasource.prereqStep2Brief_${form.type}`, t('datasource.prereqPermBrief')) }}</div>
<div class="ds-prereq-item-title">{{ t(`datasource.prereqStep2Brief_${form.type}`,
t('datasource.prereqPermBrief')) }}</div>
<div class="ds-prereq-item-desc">
<template v-if="!t(`datasource.prereqStep2Desc_${form.type}`)">
<code v-for="perm in currentDef.requiredPermissions" :key="perm" class="ds-perm-tag">{{ perm }}</code>
@@ -635,8 +654,11 @@ const stepTitles = computed(() => [
<div class="ds-prereq-item">
<span class="ds-prereq-num">3</span>
<div>
<div class="ds-prereq-item-title">{{ t(`datasource.prereqStep3Brief_${form.type}`, t('datasource.prereqMemberBrief')) }}</div>
<div class="ds-prereq-item-desc">{{ t(`datasource.prereqStep3Desc_${form.type}`, t('datasource.prereqMemberDesc')) }}</div>
<div class="ds-prereq-item-title">{{ t(`datasource.prereqStep3Brief_${form.type}`,
t('datasource.prereqMemberBrief')) }}</div>
<div class="ds-prereq-item-desc">{{ t(`datasource.prereqStep3Desc_${form.type}`,
t('datasource.prereqMemberDesc'))
}}</div>
</div>
</div>
<a :href="currentDef.permissionPageUrl" target="_blank" rel="noopener" class="doc-link ds-prereq-link">
@@ -660,32 +682,45 @@ const stepTitles = computed(() => [
</div>
<!--
Credential fields. When the server returned '***' for a field the
per-input placeholder swaps to the shared "••••••• (Enter new value
to replace)" hint so the input carries the "something is stored"
signal without a separate badge. The "Remove all credentials"
checkbox below handles the destructive clear for the whole set
(DataSource credentials are per-connector atomic bundles).
Credentials card (edit mode). DataSource credentials are a
per-connector atomic set (OAuth pair, GitHub PAT + username, etc.),
so unlike MCP/Model/WebSearch the credential subresource exposes
only one logical field. The UI here mirrors <CredentialResource>'s
three-state behavior (configured / unconfigured / editing) but with
the connector-specific form embedded inline when editing.
-->
<div v-for="field in currentDef?.fields || []" :key="field.key" class="form-item">
<label class="form-label">
{{ t(field.labelKey) }}
<span v-if="!field.optional" class="required-mark">*</span>
</label>
<t-input
v-model="form.config.credentials[field.key]"
:disabled="clearCredentials"
:placeholder="credentialPlaceholder(field)"
:type="field.secret ? 'password' : 'text'"
/>
<div v-if="field.hintKey" class="form-hint">{{ t(field.hintKey) }}</div>
<div v-if="isEdit && credentialsConfigured && !replaceCredentialsMode" class="form-item credential-card">
<div class="credential-card-row">
<span class="credential-badge">
<t-icon name="check-circle-filled" size="14px" />
{{ t('credential.configured') }}
</span>
<t-button variant="text" theme="primary" @click="replaceCredentialsMode = true">
{{ t('credential.update') }}
</t-button>
<t-button variant="text" theme="danger" @click="removeCredentials">
{{ t('credential.remove') }}
</t-button>
</div>
</div>
<div v-if="isEdit && hasExistingCredentials" class="form-item">
<t-checkbox v-model="clearCredentials" class="clear-credential">
{{ t('secret.clearHint') }}
</t-checkbox>
</div>
<template v-if="credentialsInputVisible">
<div v-for="field in currentDef?.fields || []" :key="field.key" class="form-item">
<label class="form-label">
{{ t(field.labelKey) }}
<span v-if="!field.optional" class="required-mark">*</span>
</label>
<t-input v-model="form.config.credentials[field.key]" :placeholder="field.placeholder"
:type="field.secret ? 'password' : 'text'" />
<div v-if="field.hintKey" class="form-hint">{{ t(field.hintKey) }}</div>
</div>
<div v-if="isEdit && replaceCredentialsMode" class="form-item">
<t-button variant="text" @click="replaceCredentialsMode = false; form.config.credentials = {}">
{{ t('common.cancel') }}
</t-button>
</div>
</template>
<div class="form-actions">
<t-button variant="outline" :loading="testing" @click="testConnection">
@@ -715,27 +750,16 @@ const stepTitles = computed(() => [
<p class="form-tip">{{ t('datasource.resourceHint') }}</p>
<div v-if="loadingResources" style="text-align:center;padding:20px"><t-loading /></div>
<div v-else-if="resources.length > 0" class="ds-resource-list">
<div
v-for="{ resource: r, depth } in visibleTree"
:key="r.external_id"
<div v-for="{ resource: r, depth } in visibleTree" :key="r.external_id"
:class="['ds-resource-row', { selected: checkStates.get(r.external_id) === 'checked' }]"
:style="{ paddingLeft: `${12 + depth * 24}px` }"
@click="toggleResource(r.external_id)"
>
<span
v-if="r.has_children"
class="ds-expand-btn"
@click.stop="toggleExpand(r.external_id)"
>
:style="{ paddingLeft: `${12 + depth * 24}px` }" @click="toggleResource(r.external_id)">
<span v-if="r.has_children" class="ds-expand-btn" @click.stop="toggleExpand(r.external_id)">
<t-icon :name="expandedResourceIds.has(r.external_id) ? 'chevron-down' : 'chevron-right'" size="16px" />
</span>
<span v-else class="ds-expand-placeholder" />
<t-checkbox
:checked="checkStates.get(r.external_id) === 'checked'"
:indeterminate="checkStates.get(r.external_id) === 'indeterminate'"
@click.stop
@change="toggleResource(r.external_id)"
/>
<t-checkbox :checked="checkStates.get(r.external_id) === 'checked'"
:indeterminate="checkStates.get(r.external_id) === 'indeterminate'" @click.stop
@change="toggleResource(r.external_id)" />
<div class="ds-resource-info">
<div class="ds-resource-name">{{ r.name || t('datasource.untitled') }}</div>
<div class="ds-resource-meta">
@@ -768,7 +792,8 @@ const stepTitles = computed(() => [
<t-button variant="outline" size="small" @click="loadResources">
{{ t('datasource.retryLoadResources') }}
</t-button>
<a v-if="currentDef?.permissionDocUrl" :href="currentDef.permissionDocUrl" target="_blank" rel="noopener" class="doc-link">
<a v-if="currentDef?.permissionDocUrl" :href="currentDef.permissionDocUrl" target="_blank" rel="noopener"
class="doc-link">
{{ t('datasource.permissionDocLink') }}
<t-icon name="link" class="link-icon" />
</a>
@@ -838,8 +863,14 @@ const stepTitles = computed(() => [
color: var(--td-text-color-placeholder);
}
.ds-step.active { color: var(--td-brand-color); font-weight: 600; }
.ds-step.done { color: var(--td-success-color); }
.ds-step.active {
color: var(--td-brand-color);
font-weight: 600;
}
.ds-step.done {
color: var(--td-success-color);
}
.ds-step-num {
width: 22px;
@@ -852,10 +883,21 @@ const stepTitles = computed(() => [
border: 1px solid currentColor;
}
.ds-step.active .ds-step-num { background: var(--td-brand-color); color: #fff; border-color: var(--td-brand-color); }
.ds-step.done .ds-step-num { background: var(--td-success-color); color: #fff; border-color: var(--td-success-color); }
.ds-step.active .ds-step-num {
background: var(--td-brand-color);
color: #fff;
border-color: var(--td-brand-color);
}
.ds-step-content { min-height: 200px; }
.ds-step.done .ds-step-num {
background: var(--td-success-color);
color: #fff;
border-color: var(--td-success-color);
}
.ds-step-content {
min-height: 200px;
}
/* --- Step 0: type cards --- */
.ds-type-grid {
@@ -872,13 +914,41 @@ const stepTitles = computed(() => [
transition: all 0.2s;
}
.ds-type-card:hover:not(.disabled) { border-color: var(--td-brand-color); background: var(--td-brand-color-light); }
.ds-type-card.disabled { opacity: 0.5; cursor: not-allowed; }
.ds-type-card:hover:not(.disabled) {
border-color: var(--td-brand-color);
background: var(--td-brand-color-light);
}
.ds-type-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.ds-type-name { font-size: 13px; font-weight: 600; }
.ds-type-soon { font-size: 10px; color: var(--td-text-color-placeholder); background: var(--td-bg-color-component); padding: 1px 6px; border-radius: 3px; }
.ds-type-desc { font-size: 11px; color: var(--td-text-color-secondary); line-height: 1.5; }
.ds-type-card.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ds-type-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.ds-type-name {
font-size: 13px;
font-weight: 600;
}
.ds-type-soon {
font-size: 10px;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-component);
padding: 1px 6px;
border-radius: 3px;
}
.ds-type-desc {
font-size: 11px;
color: var(--td-text-color-secondary);
line-height: 1.5;
}
/* --- Step 1: collapsible prereq --- */
.ds-prereq-bar {
@@ -982,19 +1052,56 @@ const stepTitles = computed(() => [
word-break: break-all;
}
.form-item { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: var(--td-text-color-primary); }
.required-mark { color: var(--td-error-color); margin-left: 2px; }
.form-item {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 6px;
color: var(--td-text-color-primary);
}
.required-mark {
color: var(--td-error-color);
margin-left: 2px;
}
/* Destructive-action checkbox — red label, matches the other 3 dialogs. */
.clear-credential :deep(.t-checkbox__label) {
color: var(--td-error-color);
font-size: 13px;
}
.form-tip { font-size: 12px; color: var(--td-text-color-placeholder); margin: 4px 0 12px; }
.form-hint { font-size: 12px; color: var(--td-text-color-placeholder); margin-top: 6px; line-height: 1.5; }
.form-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.test-ok { color: var(--td-success-color); font-size: 13px; display: flex; align-items: center; gap: 4px; }
.form-tip {
font-size: 12px;
color: var(--td-text-color-placeholder);
margin: 4px 0 12px;
}
.form-hint {
font-size: 12px;
color: var(--td-text-color-placeholder);
margin-top: 6px;
line-height: 1.5;
}
.form-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.test-ok {
color: var(--td-success-color);
font-size: 13px;
display: flex;
align-items: center;
gap: 4px;
}
.test-error-box {
display: flex;
@@ -1027,10 +1134,23 @@ const stepTitles = computed(() => [
word-break: break-word;
}
.ds-dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--td-border-level-2-color); }
.ds-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--td-border-level-2-color);
}
/* --- Step 2: resource list --- */
.ds-resource-list { max-height: 400px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
.ds-resource-list {
max-height: 400px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.ds-expand-btn {
display: flex;
@@ -1044,8 +1164,15 @@ const stepTitles = computed(() => [
flex-shrink: 0;
transition: background 0.15s;
}
.ds-expand-btn:hover { background: var(--td-bg-color-component-hover); }
.ds-expand-placeholder { width: 20px; flex-shrink: 0; }
.ds-expand-btn:hover {
background: var(--td-bg-color-component-hover);
}
.ds-expand-placeholder {
width: 20px;
flex-shrink: 0;
}
.ds-resource-row {
display: flex;
@@ -1161,5 +1288,4 @@ const stepTitles = computed(() => [
justify-content: center;
gap: 16px;
}
</style>

View File

@@ -6,11 +6,8 @@
<h2>{{ $t('modelSettings.title') }}</h2>
<p class="section-description">{{ $t('modelSettings.description') }}</p>
</div>
<t-dropdown
:options="addModelOptions"
placement="bottom-right"
@click="(data: any) => openAddDialog(data.value)"
>
<t-dropdown :options="addModelOptions" placement="bottom-right"
@click="(data: any) => openAddDialog(data.value)">
<t-button theme="primary" variant="outline" size="small">
<template #icon><add-icon /></template>
{{ $t('modelSettings.actions.addModel') }}
@@ -21,12 +18,8 @@
<div class="builtin-models-hint" role="note">
<p class="builtin-hint-label">{{ $t('modelSettings.builtinModels.title') }}</p>
<p class="builtin-hint-text">{{ $t('modelSettings.builtinModels.description') }}</p>
<a
class="doc-link"
href="https://github.com/Tencent/WeKnora/blob/main/docs/BUILTIN_MODELS.md"
target="_blank"
rel="noopener noreferrer"
>
<a class="doc-link" href="https://github.com/Tencent/WeKnora/blob/main/docs/BUILTIN_MODELS.md" target="_blank"
rel="noopener noreferrer">
{{ $t('modelSettings.builtinModels.viewGuide') }}
<t-icon name="link" class="link-icon" />
</a>
@@ -36,21 +29,17 @@
<t-tabs v-model="activeTypeFilter" class="model-type-tabs">
<t-tab-panel value="all" :label="`${$t('common.all')}(${allLegacyModels.length})`" />
<t-tab-panel value="chat" :label="`${$t('modelSettings.typeShort.chat')}(${countByType('chat')})`" />
<t-tab-panel value="embedding" :label="`${$t('modelSettings.typeShort.embedding')}(${countByType('embedding')})`" />
<t-tab-panel value="embedding"
:label="`${$t('modelSettings.typeShort.embedding')}(${countByType('embedding')})`" />
<t-tab-panel value="rerank" :label="`${$t('modelSettings.typeShort.rerank')}(${countByType('rerank')})`" />
<t-tab-panel value="vllm" :label="`${$t('modelSettings.typeShort.vllm')}(${countByType('vllm')})`" />
<t-tab-panel value="asr" :label="`${$t('modelSettings.typeShort.asr')}(${countByType('asr')})`" />
</t-tabs>
<div v-if="filteredModels.length > 0" class="model-grid">
<SettingCard
v-for="model in filteredModels"
:key="`${model._modelType}-${model.id}`"
:title="model.name"
:disabled="model.isBuiltin"
:actions="getModelOptions(model._modelType, model)"
@action="(value: string) => handleMenuAction({ value }, model._modelType, model)"
>
<SettingCard v-for="model in filteredModels" :key="`${model._modelType}-${model.id}`" :title="model.name"
:disabled="model.isBuiltin" :actions="getModelOptions(model._modelType, model)"
@action="(value: string) => handleMenuAction({ value }, model._modelType, model)">
<template #tags>
<t-tag size="small" variant="light" :class="`model-type-tag model-type-tag--${model._modelType}`">
{{ typeLabel(model._modelType) }}
@@ -79,11 +68,7 @@
</div>
<div v-else class="empty-state">
<t-empty :description="emptyHint">
<t-dropdown
:options="addModelOptions"
placement="bottom"
@click="(data: any) => openAddDialog(data.value)"
>
<t-dropdown :options="addModelOptions" placement="bottom" @click="(data: any) => openAddDialog(data.value)">
<t-button theme="primary" variant="outline" size="small">
<template #icon><add-icon /></template>
{{ $t('modelSettings.actions.addModel') }}
@@ -93,12 +78,8 @@
</div>
<!-- 模型编辑器抽屉 -->
<ModelEditorDialog
v-model:visible="showDialog"
:model-type="currentModelType"
:model-data="editingModel"
@confirm="handleModelSave"
/>
<ModelEditorDialog v-model:visible="showDialog" :model-type="currentModelType" :model-data="editingModel"
@confirm="handleModelSave" />
</div>
</template>
@@ -138,13 +119,10 @@ const backendTypeToModelType: Record<string, ModelType> = {
}
// 将后端模型格式转换为旧的前端格式(附带 _modelType 便于渲染)
// NOTE: apiKey is intentionally NEVER pre-filled from the server response.
// The server returns '***' as a redacted placeholder when a key is stored,
// and '' when it is not. We expose this as hasExistingApiKey so the editor
// dialog can render a "Set / Not set" badge, while the apiKey field starts
// blank — this preserves the invariant "non-empty formData.apiKey means the
// user typed something" that the save path relies on to decide preserve /
// replace / clear.
// apiKey is always blank here: the server's main GET response does not
// include it (see internal/handler/dto/model.go — ModelParametersDTO omits
// secret fields). Credential read/write happens inside the editor dialog
// via the dedicated /credentials subresource.
function convertToLegacyFormat(model: ModelConfig) {
return {
id: model.id!,
@@ -153,7 +131,6 @@ function convertToLegacyFormat(model: ModelConfig) {
modelName: model.name,
baseUrl: model.parameters.base_url || '',
apiKey: '',
hasExistingApiKey: model.parameters.api_key === '***',
provider: model.parameters.provider || '',
dimension: model.parameters.embedding_parameters?.dimension,
isBuiltin: model.is_builtin || false,
@@ -161,7 +138,10 @@ function convertToLegacyFormat(model: ModelConfig) {
customHeaders: model.parameters.custom_headers
? Object.entries(model.parameters.custom_headers).map(([key, value]) => ({ key, value: String(value) }))
: [],
_modelType: backendTypeToModelType[model.type] || 'chat' as ModelType
_modelType: backendTypeToModelType[model.type] || 'chat' as ModelType,
// Preserve the credential metadata map so the editor dialog can render
// the "Configured" state without an extra round-trip.
credentials: model.credentials,
}
}
@@ -291,19 +271,12 @@ const handleModelSave = async (modelData: any) => {
}
}
// 将前端格式转换为后端格式.
// Three-state api_key semantics (write-only secrets pattern):
// - clearApiKey set → send { clear_api_key: true }
// - user typed a value → send { api_key: "..." }
// - empty (default on edit) → omit api_key → server preserves
// - empty on create → omit api_key → no stored secret
// api_key flows in only on initial create (modelData.apiKey is wiped on
// every edit-mode open). Edits to existing models commit credentials via
// the /credentials subresource (handled inside ModelEditorDialog).
const trimmedApiKey = (modelData.apiKey ?? '').trim()
const apiKeyFields: { api_key?: string; clear_api_key?: boolean } =
modelData.clearApiKey
? { clear_api_key: true }
: trimmedApiKey
? { api_key: trimmedApiKey }
: {}
const apiKeyFields: { api_key?: string } =
!editingModel.value && trimmedApiKey ? { api_key: trimmedApiKey } : {}
const apiModelData: ModelConfig = {
name: modelData.modelName.trim(),

View File

@@ -15,14 +15,9 @@
<!-- Provider List -->
<div v-if="providerEntities.length > 0" class="provider-grid">
<SettingCard
v-for="entity in providerEntities"
:key="entity.id"
:title="entity.name"
:description="entity.description || ''"
:actions="getProviderOptions(entity)"
@action="(value: string) => handleMenuAction({ value }, entity)"
>
<SettingCard v-for="entity in providerEntities" :key="entity.id" :title="entity.name"
:description="entity.description || ''" :actions="getProviderOptions(entity)"
@action="(value: string) => handleMenuAction({ value }, entity)">
<template #tags>
<t-tag theme="primary" size="small" variant="light">
{{ entity.provider }}
@@ -54,12 +49,9 @@
</div>
<!-- Add/Edit Drawer -->
<SettingDrawer
v-model:visible="showAddProviderDialog"
<SettingDrawer v-model:visible="showAddProviderDialog"
:title="editingProvider ? t('webSearchSettings.editProvider') : t('webSearchSettings.addProvider')"
:confirm-loading="saving"
@confirm="saveProvider"
>
:confirm-loading="saving" @confirm="saveProvider">
<t-form ref="formRef" :data="providerForm" label-align="top" class="provider-form">
<t-form-item :label="t('webSearchSettings.providerTypeLabel')" name="provider">
<t-select v-model="providerForm.provider" :disabled="!!editingProvider" @change="onProviderTypeChange">
@@ -75,14 +67,16 @@
</t-form-item>
<t-form-item :label="t('webSearchSettings.providerNameLabel')" name="name">
<t-input v-model="providerForm.name" :placeholder="selectedProviderType?.name || t('webSearchSettings.providerNamePlaceholder')" />
<t-input v-model="providerForm.name"
:placeholder="selectedProviderType?.name || t('webSearchSettings.providerNamePlaceholder')" />
</t-form-item>
<t-form-item :label="t('webSearchSettings.providerDescLabel')" name="description">
<t-input v-model="providerForm.description" :placeholder="t('webSearchSettings.providerDescPlaceholder')" />
</t-form-item>
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id || selectedProviderType?.requires_base_url">
<template
v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id || selectedProviderType?.requires_base_url">
<div class="form-divider"></div>
<div class="credentials-hint" v-if="selectedProviderType?.docs_url">
@@ -92,38 +86,39 @@
</a>
</div>
<t-form-item v-if="selectedProviderType?.requires_base_url" :label="t('webSearchSettings.baseUrlLabel')" name="parameters.base_url">
<t-input
v-model="providerForm.parameters.base_url"
:placeholder="t('webSearchSettings.baseUrlPlaceholder')"
/>
<t-form-item v-if="selectedProviderType?.requires_base_url" :label="t('webSearchSettings.baseUrlLabel')"
name="parameters.base_url">
<t-input v-model="providerForm.parameters.base_url"
:placeholder="t('webSearchSettings.baseUrlPlaceholder')" />
</t-form-item>
<!--
Edit mode: credential is managed by the shared <CredentialResource>
card via the /credentials subresource. Create mode keeps a plain
input so the initial api_key flows in with the first POST.
API key is mandatory for every requires_api_key=true provider
(Bing / Google / Tavily / Ollama / Baidu see
validateProviderParameters in service/web_search_provider.go),
so the "Remove this credential" affordance shown in the other
three dialogs is intentionally omitted here. Revoking access
means deleting the whole provider row from the list.
(validation in service/web_search_provider.go). The component's
"Remove" action is still available because a user might want to
rotate via remove + re-add, but in normal use they will use
"Replace" instead.
-->
<div v-if="selectedProviderType?.requires_api_key" class="credential-field">
<label class="credential-label">{{ t('webSearchSettings.apiKeyLabel') }}</label>
<t-input
v-model="providerForm.parameters.api_key"
type="password"
:placeholder="apiKeyPlaceholder"
/>
<CredentialResource v-if="editingProvider?.id" :api="credentialApi" :fields="credentialFields"
:meta="credentialMeta" />
<t-input v-else v-model="providerForm.parameters.api_key" type="password"
:placeholder="apiKeyPlaceholder" />
</div>
<t-form-item v-if="selectedProviderType?.requires_engine_id" :label="t('webSearchSettings.engineIdLabel')" name="parameters.engine_id">
<t-form-item v-if="selectedProviderType?.requires_engine_id" :label="t('webSearchSettings.engineIdLabel')"
name="parameters.engine_id">
<t-input v-model="providerForm.parameters.engine_id" :placeholder="t('webSearchSettings.engineIdLabel')" />
</t-form-item>
</template>
<t-form-item v-if="selectedProviderType?.supports_proxy" :label="t('webSearchSettings.proxyUrlLabel')" name="parameters.proxy_url">
<t-input
v-model="providerForm.parameters.proxy_url"
:placeholder="t('webSearchSettings.proxyUrlPlaceholder')"
/>
<t-form-item v-if="selectedProviderType?.supports_proxy" :label="t('webSearchSettings.proxyUrlLabel')"
name="parameters.proxy_url">
<t-input v-model="providerForm.parameters.proxy_url"
:placeholder="t('webSearchSettings.proxyUrlPlaceholder')" />
<template #help>
<span class="switch-help">{{ t('webSearchSettings.proxyUrlHelp') }}</span>
</template>
@@ -142,13 +137,8 @@
</t-form>
<template #footer-left>
<t-button
v-if="selectedProviderType && !isProviderFree(selectedProviderType)"
theme="default"
variant="outline"
:loading="testing"
@click="testConnection"
>
<t-button v-if="selectedProviderType && !isProviderFree(selectedProviderType)" theme="default" variant="outline"
:loading="testing" @click="testConnection">
{{ testing ? t('webSearchSettings.testing') : t('webSearchSettings.testConnection') }}
</t-button>
</template>
@@ -168,11 +158,18 @@ import {
updateWebSearchProvider,
deleteWebSearchProvider as deleteWebSearchProviderAPI,
testWebSearchProvider,
putWebSearchProviderCredentials,
deleteWebSearchProviderCredentialField,
type WebSearchProviderEntity,
type WebSearchProviderTypeInfo,
type WebSearchCredentialField,
} from '@/api/web-search-provider'
import SettingCard from '@/components/settings/SettingCard.vue'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
import CredentialResource, {
type CredentialFieldDef,
type CredentialResourceApi,
} from '@/components/credentials/CredentialResource.vue'
import { useConfirmDelete } from '@/components/settings/useConfirmDelete'
const { t } = useI18n()
@@ -188,10 +185,6 @@ const testingId = ref<string | null>(null)
const saving = ref(false)
const formRef = ref<any>()
// Fixed placeholder returned by the server for redacted secrets. Must match
// internal/types/secret.go → RedactedSecretPlaceholder.
const REDACTED_PLACEHOLDER = '***'
const providerForm = ref<{
name: string
provider: string
@@ -211,21 +204,31 @@ const selectedProviderType = computed(() => {
return providerTypes.value.find(pt => pt.id === providerForm.value.provider)
})
// "Is an API key currently stored?" is signaled by the server returning the
// fixed REDACTED_PLACEHOLDER in the response. Drive the label badge from this.
const hasExistingApiKey = computed(() => {
return editingProvider.value?.parameters?.api_key === REDACTED_PLACEHOLDER
// Create-mode placeholder (edit mode replaces the input with
// <CredentialResource>, which has its own placeholder).
const apiKeyPlaceholder = computed(() => t('webSearchSettings.apiKeyPlaceholder'))
const credentialFields = computed<CredentialFieldDef<WebSearchCredentialField>[]>(() => [
{ key: 'api_key', label: t('webSearchSettings.apiKeyLabel') as string },
])
const credentialApi = computed<CredentialResourceApi<WebSearchCredentialField>>(() => {
const id = editingProvider.value?.id ?? ''
return {
save: async (patch) => {
const meta = await putWebSearchProviderCredentials(id, patch)
return meta.fields
},
remove: async (field) => {
await deleteWebSearchProviderCredentialField(id, field)
},
}
})
// In edit mode with a stored key, swap the placeholder to the shared
// "bullets + Enter new value to replace" hint. Otherwise fall back to the
// provider-specific placeholder (creation) or the "leave blank to keep"
// copy used when no key is stored yet.
const apiKeyPlaceholder = computed(() => {
if (!editingProvider.value) return t('webSearchSettings.apiKeyPlaceholder')
return hasExistingApiKey.value
? t('secret.storedPlaceholder')
: t('webSearchSettings.apiKeyUnchanged')
// Initial configured? from the main provider response (embedded server-side
// in dto.WebSearchProviderResponse.Credentials).
const credentialMeta = computed(() => editingProvider.value?.credentials ?? {
api_key: { configured: false },
})
const isProviderFree = (providerType: WebSearchProviderTypeInfo) => {
@@ -305,18 +308,15 @@ const saveProvider = async () => {
saving.value = true
try {
// Build the parameters payload using two-state semantics:
// - user typed a value → send api_key (replaces stored)
// - empty + editing → omit api_key (server preserves stored value)
// - empty + creating → omit api_key (no secret to store yet)
// No clear path: every requires_api_key provider mandates a value, so
// "revoke" means deleting the provider row, not zeroing the key.
// Build the parameters payload. api_key only flows in on initial
// create — edit mode commits credentials through <CredentialResource>
// (a dedicated PUT /credentials call) before this save runs.
const paramsOut: WebSearchProviderEntity['parameters'] = {
engine_id: providerForm.value.parameters.engine_id,
base_url: providerForm.value.parameters.base_url,
proxy_url: providerForm.value.parameters.proxy_url,
}
if (providerForm.value.parameters.api_key) {
if (!editingProvider.value && providerForm.value.parameters.api_key) {
paramsOut.api_key = providerForm.value.parameters.api_key
}

View File

@@ -1,28 +1,16 @@
<template>
<SettingDrawer
:visible="dialogVisible"
<SettingDrawer :visible="dialogVisible"
:title="mode === 'add' ? t('mcpServiceDialog.addTitle') : t('mcpServiceDialog.editTitle')"
:confirm-loading="submitting"
@update:visible="(v: boolean) => dialogVisible = v"
@confirm="handleSubmit"
@cancel="handleClose"
>
<t-form
ref="formRef"
:data="formData"
:rules="rules"
label-align="top"
>
:confirm-loading="submitting" @update:visible="(v: boolean) => dialogVisible = v" @confirm="handleSubmit"
@cancel="handleClose">
<t-form ref="formRef" :data="formData" :rules="rules" label-align="top">
<t-form-item :label="t('mcpServiceDialog.name')" name="name">
<t-input v-model="formData.name" :placeholder="t('mcpServiceDialog.namePlaceholder')" />
</t-form-item>
<t-form-item :label="t('mcpServiceDialog.description')" name="description">
<t-textarea
v-model="formData.description"
:autosize="{ minRows: 3, maxRows: 5 }"
:placeholder="t('mcpServiceDialog.descriptionPlaceholder')"
/>
<t-textarea v-model="formData.description" :autosize="{ minRows: 3, maxRows: 5 }"
:placeholder="t('mcpServiceDialog.descriptionPlaceholder')" />
</t-form-item>
<t-form-item :label="t('mcpServiceDialog.transportType')" name="transport_type">
@@ -34,10 +22,7 @@
</t-form-item>
<!-- URL for SSE/HTTP Streamable -->
<t-form-item
:label="t('mcpServiceDialog.serviceUrl')"
name="url"
>
<t-form-item :label="t('mcpServiceDialog.serviceUrl')" name="url">
<t-input v-model="formData.url" :placeholder="t('mcpServiceDialog.serviceUrlPlaceholder')" />
</t-form-item>
@@ -51,73 +36,40 @@
<t-collapse :default-value="[]">
<t-collapse-panel :header="t('mcpServiceDialog.authConfig')" value="auth">
<!--
Credential fields follow the "write-only secrets" pattern.
When a value is currently stored on the server the placeholder
is swapped for a bullet run + "Enter new value to replace"
hint, so the visual "something is there" signal is carried by
the input itself rather than by a separate Set/Not-set badge.
The Remove checkbox still offers an explicit clear path.
-->
<div class="credential-field">
<label class="credential-label">{{ t('mcpServiceDialog.apiKey') }}</label>
<t-input
v-model="formData.auth_config.api_key"
type="password"
:disabled="clearApiKey"
:placeholder="apiKeyPlaceholder"
/>
<t-checkbox
v-if="mode === 'edit' && hasExistingApiKey"
v-model="clearApiKey"
class="clear-credential"
>
{{ t('secret.clearHint') }}
</t-checkbox>
</div>
Edit mode: credentials live behind a dedicated subresource. The
CredentialResource component drives configured/unconfigured/editing
state per field and commits each change to /credentials directly,
decoupling credential edits from the surrounding form.
<div class="credential-field">
<label class="credential-label">{{ t('mcpServiceDialog.bearerToken') }}</label>
<t-input
v-model="formData.auth_config.token"
type="password"
:disabled="clearToken"
:placeholder="tokenPlaceholder"
/>
<t-checkbox
v-if="mode === 'edit' && hasExistingToken"
v-model="clearToken"
class="clear-credential"
>
{{ t('secret.clearHint') }}
</t-checkbox>
</div>
Add mode: the resource doesn't exist yet, so we accept the initial
credentials as plain inputs and POST them together with the rest of
the service in handleSubmit. From the second save onwards, the
edit-mode path takes over.
-->
<CredentialResource v-if="mode === 'edit' && props.service?.id" :api="credentialApi"
:fields="credentialFields" :meta="credentialMeta" />
<template v-else>
<t-form-item :label="t('mcpServiceDialog.apiKey')">
<t-input v-model="formData.auth_config.api_key" type="password"
:placeholder="t('mcpServiceDialog.optional')" />
</t-form-item>
<t-form-item :label="t('mcpServiceDialog.bearerToken')">
<t-input v-model="formData.auth_config.token" type="password"
:placeholder="t('mcpServiceDialog.optional')" />
</t-form-item>
</template>
</t-collapse-panel>
<!-- Advanced Config -->
<t-collapse-panel :header="t('mcpServiceDialog.advancedConfig')" value="advanced">
<t-form-item :label="t('mcpServiceDialog.timeoutSec')">
<t-input-number
v-model="formData.advanced_config.timeout"
:min="1"
:max="300"
placeholder="30"
/>
<t-input-number v-model="formData.advanced_config.timeout" :min="1" :max="300" placeholder="30" />
</t-form-item>
<t-form-item :label="t('mcpServiceDialog.retryCount')">
<t-input-number
v-model="formData.advanced_config.retry_count"
:min="0"
:max="10"
placeholder="3"
/>
<t-input-number v-model="formData.advanced_config.retry_count" :min="0" :max="10" placeholder="3" />
</t-form-item>
<t-form-item :label="t('mcpServiceDialog.retryDelaySec')">
<t-input-number
v-model="formData.advanced_config.retry_delay"
:min="0"
:max="60"
placeholder="1"
/>
<t-input-number v-model="formData.advanced_config.retry_delay" :min="0" :max="60" placeholder="1" />
</t-form-item>
</t-collapse-panel>
</t-collapse>
@@ -127,15 +79,22 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { MessagePlugin } from 'tdesign-vue-next'
import type { FormInstanceFunctions, FormRule } from 'tdesign-vue-next'
import { useI18n } from 'vue-i18n'
import {
createMCPService,
updateMCPService,
type MCPService
putMCPCredentials,
deleteMCPCredentialField,
type MCPService,
type McpCredentialField,
} from '@/api/mcp-service'
import SettingDrawer from '@/components/settings/SettingDrawer.vue'
import CredentialResource, {
type CredentialFieldDef,
type CredentialResourceApi,
} from '@/components/credentials/CredentialResource.vue'
interface Props {
visible: boolean
@@ -155,10 +114,6 @@ const formRef = ref<FormInstanceFunctions>()
const submitting = ref(false)
const { t } = useI18n()
// Fixed placeholder returned by the server for sensitive fields whose values
// are withheld. Must match internal/types/secret.go → RedactedSecretPlaceholder.
const REDACTED_PLACEHOLDER = '***'
const formData = ref({
name: '',
description: '',
@@ -166,44 +121,48 @@ const formData = ref({
transport_type: 'sse' as 'sse' | 'http-streamable',
url: '',
auth_config: {
// Only used in add-mode; in edit-mode the CredentialResource owns these.
api_key: '',
token: ''
token: '',
},
advanced_config: {
timeout: 30,
retry_count: 3,
retry_delay: 1
retry_delay: 1,
},
})
// Field metadata for the credential subresource. Keep label keys local to
// MCP so other resources don't accidentally inherit "API Key" / "Bearer
// Token" labels via the shared component.
const credentialFields = computed<CredentialFieldDef<McpCredentialField>[]>(() => [
{ key: 'api_key', label: t('mcpServiceDialog.apiKey') },
{ key: 'token', label: t('mcpServiceDialog.bearerToken') },
])
// Adapter that binds the generic CredentialResource component to the MCP
// credential endpoints. Recomputed if the user opens a different service.
const credentialApi = computed<CredentialResourceApi<McpCredentialField>>(() => {
const id = props.service?.id ?? ''
return {
save: async (patch) => {
const meta = await putMCPCredentials(id, patch)
return meta.fields
},
remove: async (field) => {
await deleteMCPCredentialField(id, field)
},
}
})
// Explicit "remove stored credential" flags. Reset to false on every open/reset.
const clearApiKey = ref(false)
const clearToken = ref(false)
// "Is a credential currently stored?" is signaled by the server returning the
// fixed REDACTED_PLACEHOLDER instead of an empty string. Drive the label
// badge and placeholder hint from this signal.
const hasExistingApiKey = computed(
() => props.service?.auth_config?.api_key === REDACTED_PLACEHOLDER
)
const hasExistingToken = computed(
() => props.service?.auth_config?.token === REDACTED_PLACEHOLDER
)
// Placeholders swap to the shared "stored credential" hint when the server
// signals a value is present (parameters come back as '***'). Otherwise
// the field is genuinely empty and we fall through to the normal
// "Optional" placeholder that appears in add mode too.
const apiKeyPlaceholder = computed(() =>
hasExistingApiKey.value
? t('secret.storedPlaceholder')
: t('mcpServiceDialog.optional')
)
const tokenPlaceholder = computed(() =>
hasExistingToken.value
? t('secret.storedPlaceholder')
: t('mcpServiceDialog.optional')
)
// Initial "configured?" metadata read from the main service response. The
// component reads this on mount; subsequent state changes after save/remove
// are tracked locally by the component itself (and re-derived from this
// whenever the parent reloads the service).
const credentialMeta = computed(() => props.service?.credentials ?? {
api_key: { configured: false },
token: { configured: false },
})
const rules: Record<string, FormRule[]> = {
name: [{ required: true, message: t('mcpServiceDialog.rules.nameRequired') as string, type: 'error' }],
@@ -214,27 +173,22 @@ const rules: Record<string, FormRule[]> = {
if (!val || val.trim() === '') {
return { result: false, message: t('mcpServiceDialog.rules.urlRequired') as string, type: 'error' }
}
// Basic URL validation
try {
new URL(val)
return { result: true, message: '', type: 'success' }
} catch {
return { result: false, message: t('mcpServiceDialog.rules.urlInvalid') as string, type: 'error' }
}
}
}
]
},
},
],
}
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
set: (value) => emit('update:visible', value),
})
// Reset form function — defined before watch to avoid hoisting issues.
// Sensitive fields are always blank: the watch below does not copy the
// server-provided redacted placeholder into formData, and clear flags are
// reset so a reopen does not carry over an aborted deletion intent.
const resetForm = () => {
formData.value = {
name: '',
@@ -242,31 +196,16 @@ const resetForm = () => {
enabled: true,
transport_type: 'sse',
url: '',
auth_config: {
api_key: '',
token: ''
},
advanced_config: {
timeout: 30,
retry_count: 3,
retry_delay: 1
}
auth_config: { api_key: '', token: '' },
advanced_config: { timeout: 30, retry_count: 3, retry_delay: 1 },
}
clearApiKey.value = false
clearToken.value = false
formRef.value?.clearValidate()
}
// Watch service prop to initialize form.
// Sensitive fields (api_key, token) are NEVER pre-filled from the server
// response — even when the server returns '***' indicating a stored value.
// This keeps the "non-empty means user typed it" invariant that the submit
// logic relies on to decide between preserve and replace.
watch(
() => props.service,
(service) => {
if (service) {
// Note: stdio transport_type will fall back to 'sse' as stdio is disabled
const transportType = service.transport_type === 'stdio' ? 'sse' : (service.transport_type || 'sse')
formData.value = {
name: service.name || '',
@@ -274,76 +213,28 @@ watch(
enabled: service.enabled ?? true,
transport_type: transportType as 'sse' | 'http-streamable',
url: service.url || '',
auth_config: {
api_key: '',
token: ''
},
// Credentials are owned by CredentialResource in edit mode, but reset
// the local state too so a switch to add-mode starts clean.
auth_config: { api_key: '', token: '' },
advanced_config: {
timeout: service.advanced_config?.timeout || 30,
retry_count: service.advanced_config?.retry_count || 3,
retry_delay: service.advanced_config?.retry_delay || 1
}
retry_delay: service.advanced_config?.retry_delay || 1,
},
}
clearApiKey.value = false
clearToken.value = false
} else {
resetForm()
}
},
{ immediate: true }
{ immediate: true },
)
// Prompt the user before irrevocable credential removal.
const confirmClearIfNeeded = (): Promise<boolean> => {
if (!clearApiKey.value && !clearToken.value) return Promise.resolve(true)
return new Promise((resolve) => {
const d = DialogPlugin.confirm({
header: t('secret.confirmClearTitle'),
body: t('secret.confirmClearBody'),
confirmBtn: { content: t('common.confirm'), theme: 'danger' },
cancelBtn: t('common.cancel'),
onConfirm: () => {
d.hide()
resolve(true)
},
onCancel: () => {
d.hide()
resolve(false)
},
onClose: () => {
d.hide()
resolve(false)
}
})
})
}
// Handle submit — three-state auth_config payload:
// - clear flag set → send { clear_api_key: true } / { clear_token: true }
// - user typed a value → send the value
// - otherwise → omit the field, server preserves the stored value
// If auth_config would be empty, omit the key entirely.
const handleSubmit = async () => {
const valid = await formRef.value?.validate()
if (!valid) return
const proceed = await confirmClearIfNeeded()
if (!proceed) return
submitting.value = true
try {
const authPayload: Record<string, unknown> = {}
if (clearApiKey.value) {
authPayload.clear_api_key = true
} else if (formData.value.auth_config.api_key) {
authPayload.api_key = formData.value.auth_config.api_key
}
if (clearToken.value) {
authPayload.clear_token = true
} else if (formData.value.auth_config.token) {
authPayload.token = formData.value.auth_config.token
}
const data: Partial<MCPService> = {
name: formData.value.name,
description: formData.value.description,
@@ -352,14 +243,19 @@ const handleSubmit = async () => {
advanced_config: formData.value.advanced_config,
url: formData.value.url || undefined,
}
if (Object.keys(authPayload).length > 0) {
data.auth_config = authPayload as MCPService['auth_config']
}
if (props.mode === 'add') {
// Initial credentials go along with the first POST. Subsequent edits
// route through the /credentials subresource.
const initialAuth: NonNullable<MCPService['auth_config']> = {}
if (formData.value.auth_config.api_key) initialAuth.api_key = formData.value.auth_config.api_key
if (formData.value.auth_config.token) initialAuth.token = formData.value.auth_config.token
if (Object.keys(initialAuth).length > 0) data.auth_config = initialAuth
await createMCPService(data)
MessagePlugin.success(t('mcpServiceDialog.toasts.created'))
} else {
// Edit-mode: never send credential fields here. CredentialResource
// already committed any changes through the dedicated endpoint.
await updateMCPService(props.service!.id, data)
MessagePlugin.success(t('mcpServiceDialog.toasts.updated'))
}
@@ -367,7 +263,9 @@ const handleSubmit = async () => {
emit('success')
} catch (error) {
MessagePlugin.error(
props.mode === 'add' ? (t('mcpServiceDialog.toasts.createFailed') as string) : (t('mcpServiceDialog.toasts.updateFailed') as string)
props.mode === 'add'
? (t('mcpServiceDialog.toasts.createFailed') as string)
: (t('mcpServiceDialog.toasts.updateFailed') as string),
)
console.error('Failed to save MCP service:', error)
} finally {
@@ -375,7 +273,6 @@ const handleSubmit = async () => {
}
}
// Handle close
const handleClose = () => {
dialogVisible.value = false
}
@@ -383,37 +280,4 @@ const handleClose = () => {
<style scoped lang="less">
/* Stdio-related styles removed as stdio transport is disabled for security reasons */
/**
* Credential field: stacks the label row, the password input, and the
* optional "Remove this credential" checkbox vertically. Replaces the
* t-form-item-based layout for the auth-config fields so the inline badge
* doesn't get clipped by the form's 120px label-width and the clear
* checkbox doesn't sit alongside the input.
*/
.credential-field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
.credential-label {
display: block;
font-size: 14px;
color: var(--td-text-color-primary);
line-height: 22px;
}
.clear-credential {
:deep(.t-checkbox__label) {
color: var(--td-error-color);
font-size: 13px;
}
}
</style>

View File

@@ -155,31 +155,27 @@ func (s *DataSourceService) UpdateDataSource(ctx context.Context, ds *types.Data
return nil, datasource.ErrDataSourceInvalid
}
// Apply write-only secret merge semantics to the Config jsonb:
// empty / redacted credential strings preserve existing values,
// ClearCredentials wipes the whole map, other values replace. This
// blocks the NELO-1299-style round-trip bug where a frontend that
// echoed back the redacted placeholder would overwrite real secrets.
//
// We also remember the parsed configs for the post-merge "changed?"
// comparison — a raw-bytes compare against ds.Config is unreliable
// because Go map iteration order makes the re-serialized JSON's key
// ordering nondeterministic, causing string(merged) != string(existing)
// to spuriously trip every time.
// Credentials NEVER flow through this endpoint — they live behind the
// /credentials subresource. Force-preserve the stored credentials map
// regardless of what the body says. Log a warning if a stale caller
// passes one so we can spot them and migrate later. Non-credential
// fields of Config (Type / ResourceIDs / Settings) flow through.
var mergedCfg, existingParsedCfg *types.DataSourceConfig
if len(ds.Config) > 0 {
incomingCfg, parseIncErr := ds.ParseConfig()
existingCfg, parseExErr := existing.ParseConfig()
if parseIncErr == nil && parseExErr == nil && incomingCfg != nil {
baseExisting := types.DataSourceConfig{}
if existingCfg != nil {
baseExisting = *existingCfg
}
if incomingCfg.ClearCredentials {
logger.Infof(ctx, "DataSource credentials cleared by user: id=%s",
if incomingCfg.HasCredentials() {
logger.Warnf(ctx,
"deprecated: credentials in PUT /datasource/%s body are ignored; use PUT /credentials instead",
secutils.SanitizeForLog(ds.ID))
}
merged := incomingCfg.MergeUpdate(baseExisting)
merged := *incomingCfg
if existingCfg != nil {
merged.Credentials = existingCfg.Credentials
} else {
merged.Credentials = nil
}
if blob, err := merged.ToJSON(); err == nil {
ds.Config = blob
}
@@ -188,22 +184,16 @@ func (s *DataSourceService) UpdateDataSource(ctx context.Context, ds *types.Data
}
}
// Validate new configuration if changed. Compare parsed structures to
// avoid re-running (potentially expensive) OAuth / live-connection
// validators on every update that only re-orders JSON keys.
//
// Skip validation entirely when the user is explicitly revoking
// credentials (ClearCredentials=true). Live validators (e.g. Notion's
// /me API) require a working secret by definition; trying to validate
// after a wipe would always fail and leave the row un-cleared, defeating
// the whole point of the "Remove all credentials" affordance.
// Validate new configuration if non-credential fields changed. Skip
// when there are no stored credentials yet (validators would fail with
// no token to call the live API) and when the parsed config is
// structurally identical.
configActuallyChanged := true
if mergedCfg != nil && existingParsedCfg != nil {
configActuallyChanged = !reflect.DeepEqual(*mergedCfg, *existingParsedCfg)
}
clearingCredentials := mergedCfg != nil && !mergedCfg.HasCredentials() &&
existingParsedCfg != nil && existingParsedCfg.HasCredentials()
if !clearingCredentials && (ds.Type != existing.Type || configActuallyChanged) {
hasCreds := mergedCfg != nil && mergedCfg.HasCredentials()
if hasCreds && (ds.Type != existing.Type || configActuallyChanged) {
if err := s.validateDataSourceConfig(ctx, ds); err != nil {
return nil, err
}
@@ -223,6 +213,78 @@ func (s *DataSourceService) UpdateDataSource(ctx context.Context, ds *types.Data
return ds, nil
}
// UpdateDataSourceCredentials replaces the connector credential map. This is
// a single atomic write; the previous credential set is discarded entirely
// (callers cannot patch individual keys because half-configured connector
// auth is meaningless). After persisting, the live connection is validated
// so the caller learns immediately if the new credentials are wrong.
func (s *DataSourceService) UpdateDataSourceCredentials(
ctx context.Context, id string, credentials map[string]interface{},
) (*types.DataSource, error) {
if id == "" {
return nil, datasource.ErrDataSourceInvalid
}
existing, err := s.dsRepo.FindByID(ctx, id)
if err != nil {
return nil, err
}
parsed, err := existing.ParseConfig()
if err != nil {
return nil, err
}
if parsed == nil {
parsed = &types.DataSourceConfig{Type: existing.Type}
}
parsed.Credentials = credentials
blob, err := parsed.ToJSON()
if err != nil {
return nil, err
}
existing.Config = blob
// Run live validation now that the credentials are in place — surfaces
// "wrong token" feedback immediately to the user instead of waiting for
// the next scheduled sync.
if err := s.validateDataSourceConfig(ctx, existing); err != nil {
return nil, err
}
if err := s.dsRepo.Update(ctx, existing); err != nil {
return nil, err
}
logger.Infof(ctx, "DataSource credentials updated: id=%s", secutils.SanitizeForLog(id))
return existing, nil
}
// ClearDataSourceCredentials wipes the connector credential map without
// touching any other config field. Idempotent.
func (s *DataSourceService) ClearDataSourceCredentials(ctx context.Context, id string) error {
if id == "" {
return datasource.ErrDataSourceInvalid
}
existing, err := s.dsRepo.FindByID(ctx, id)
if err != nil {
return err
}
parsed, err := existing.ParseConfig()
if err != nil {
return err
}
if parsed == nil || !parsed.HasCredentials() {
return nil
}
parsed.Credentials = nil
blob, err := parsed.ToJSON()
if err != nil {
return err
}
existing.Config = blob
if err := s.dsRepo.Update(ctx, existing); err != nil {
return err
}
logger.Infof(ctx, "DataSource credentials cleared by user: id=%s", secutils.SanitizeForLog(id))
return nil
}
// DeleteDataSource deletes a data source (soft delete)
func (s *DataSourceService) DeleteDataSource(ctx context.Context, id string) error {
// Verify data source exists

View File

@@ -57,10 +57,11 @@ func (s *mcpServiceService) CreateMCPService(ctx context.Context, service *types
// GetMCPServiceByID retrieves an MCP service by ID.
//
// Sensitive fields are redacted before the service is returned so that the
// single-resource GET behaves consistently with the list endpoint. Builtin
// services still route through HideSensitiveInfo which clears additional
// fields (URL, Headers, EnvVars, StdioConfig) on top of the redaction.
// Returns the raw stored entity including any AuthConfig credentials in plain
// form. Callers MUST convert to dto.MCPServiceResponse (which strips secret
// fields by construction) before serializing to a response body. Internal
// callers (e.g. MCP client construction, credential metadata derivation) need
// the unredacted form to function correctly.
func (s *mcpServiceService) GetMCPServiceByID(
ctx context.Context,
tenantID uint64,
@@ -75,33 +76,19 @@ func (s *mcpServiceService) GetMCPServiceByID(
if service == nil {
return nil, fmt.Errorf("MCP service not found")
}
if service.IsBuiltin {
return service.HideSensitiveInfo(), nil
}
service.RedactSensitiveData()
return service, nil
}
// ListMCPServices lists all MCP services for a tenant
// ListMCPServices lists all MCP services for a tenant.
//
// Same contract as GetMCPServiceByID — returns raw entities; handlers MUST
// convert to dto.MCPServiceResponse before responding.
func (s *mcpServiceService) ListMCPServices(ctx context.Context, tenantID uint64) ([]*types.MCPService, error) {
services, err := s.mcpServiceRepo.List(ctx, tenantID)
if err != nil {
logger.GetLogger(ctx).Errorf("Failed to list MCP services: %v", err)
return nil, fmt.Errorf("failed to list MCP services: %w", err)
}
// Redact sensitive data before returning so secrets never leave the server.
// Builtin services go through HideSensitiveInfo which clears additional
// fields (URL, headers, env_vars, stdio_config) beyond redaction.
for i, service := range services {
if service.IsBuiltin {
services[i] = service.HideSensitiveInfo()
} else {
service.RedactSensitiveData()
}
}
return services, nil
}
@@ -158,6 +145,11 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
// this because the in-place merge below reassigns pointer fields such as
// existing.URL = service.URL, after which any post-merge comparison
// between service.URL and existing.URL would trivially match.
//
// AuthConfig is intentionally NOT snapshotted/compared here — credential
// changes now flow through the dedicated /credentials subresource which
// handles its own CloseClient call. Main PUT does not accept secret
// fields (see handler comment on UpdateMCPService).
preURL := ""
preURLSet := existing.URL != nil
if preURLSet {
@@ -171,38 +163,19 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
preStdioArgs = append([]string(nil), existing.StdioConfig.Args...)
}
preTransportType := existing.TransportType
preAuthSet := existing.AuthConfig != nil
var preAuthAPIKey, preAuthToken string
var preAuthHeaders map[string]string
if preAuthSet {
preAuthAPIKey = existing.AuthConfig.APIKey
preAuthToken = existing.AuthConfig.Token
// Copy the map so subsequent mutations via merged.CustomHeaders don't
// alias into the snapshot.
if existing.AuthConfig.CustomHeaders != nil {
preAuthHeaders = make(map[string]string, len(existing.AuthConfig.CustomHeaders))
maps.Copy(preAuthHeaders, existing.AuthConfig.CustomHeaders)
}
preHeaders := map[string]string{}
if existing.AuthConfig != nil && existing.AuthConfig.CustomHeaders != nil {
maps.Copy(preHeaders, existing.AuthConfig.CustomHeaders)
}
// AuthConfig merge is applied unconditionally (both partial and full
// updates), so a request body like {"clear_token": true} by itself is
// honored instead of being silently dropped when Name is empty.
// Audit-log explicit clear operations before the merge absorbs the flag.
if service.AuthConfig != nil {
if service.AuthConfig.ClearAPIKey {
logger.GetLogger(ctx).Infof(
"MCP auth cleared by user: id=%s field=api_key",
secutils.SanitizeForLog(service.ID),
)
// CustomHeaders flows through main PUT (it's structural, not a secret) —
// nil preserves, non-nil replaces. Other AuthConfig fields (APIKey/Token)
// are never accepted via main PUT; the handler strips them up front.
if service.AuthConfig != nil && service.AuthConfig.CustomHeaders != nil {
if existing.AuthConfig == nil {
existing.AuthConfig = &types.MCPAuthConfig{}
}
if service.AuthConfig.ClearToken {
logger.GetLogger(ctx).Infof(
"MCP auth cleared by user: id=%s field=token",
secutils.SanitizeForLog(service.ID),
)
}
existing.AuthConfig = service.AuthConfig.MergeUpdate(existing.AuthConfig)
existing.AuthConfig.CustomHeaders = service.AuthConfig.CustomHeaders
}
// Merge updates: only update fields that are provided (non-zero or explicitly set)
@@ -212,7 +185,7 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
// or if we're updating other fields (indicating full update)
// For enabled field, we'll update it if this is a partial update (only enabled) or if it's explicitly set
if service.Name == "" {
// Partial update - only update enabled field (AuthConfig already merged above).
// Partial update - only update enabled field.
existing.Enabled = service.Enabled
} else {
// Full update - update all fields including enabled
@@ -250,12 +223,14 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
}
// Check if critical configuration changed (URL / StdioConfig / transport
// type / auth config). Comparisons MUST be against the pre-merge
// type / custom headers). Comparisons MUST be against the pre-merge
// snapshots captured above — after the in-place merge, service.URL and
// existing.URL point to the same memory, making any post-merge compare
// vacuously equal. Saving the dialog without touching these fields must
// not recycle the live MCP client connection (that's the NELO-1299
// regression we're preventing).
// vacuously equal.
//
// AuthConfig API key / token changes do NOT go through this path; they
// are handled by the /credentials subresource which triggers CloseClient
// inline.
configChanged := false
currURLSet := existing.URL != nil
switch {
@@ -275,13 +250,11 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types
if existing.TransportType != preTransportType {
configChanged = true
}
currAuthSet := existing.AuthConfig != nil
switch {
case currAuthSet != preAuthSet:
configChanged = true
case currAuthSet && (existing.AuthConfig.APIKey != preAuthAPIKey ||
existing.AuthConfig.Token != preAuthToken ||
!maps.Equal(existing.AuthConfig.CustomHeaders, preAuthHeaders)):
currHeaders := map[string]string{}
if existing.AuthConfig != nil && existing.AuthConfig.CustomHeaders != nil {
currHeaders = existing.AuthConfig.CustomHeaders
}
if !maps.Equal(currHeaders, preHeaders) {
configChanged = true
}
name := secutils.SanitizeForLog(existing.Name)
@@ -438,6 +411,114 @@ func (s *mcpServiceService) GetMCPServiceTools(
return tools, nil
}
// UpdateMCPCredentials writes one or more credential fields and recycles any
// active client connection so the next upstream call uses the new credential.
//
// Implementation notes:
// - apiKey == nil and token == nil → no-op, returns current state.
// - apiKey == &"" → explicit empty string; treated as no-op because clearing
// is the dedicated ClearMCPCredential path. The handler enforces this
// contract by accepting empty as no-op too; this is defense-in-depth.
// - apiKey == &"sk-..." → replaces stored value.
// - Builtin services cannot have credentials updated (mirrors the
// UpdateMCPService restriction).
// - Always re-fetches existing AuthConfig before merge to avoid clobbering
// CustomHeaders or the unrelated credential field.
func (s *mcpServiceService) UpdateMCPCredentials(
ctx context.Context, tenantID uint64, id string, apiKey *string, token *string,
) (*types.MCPService, error) {
existing, err := s.mcpServiceRepo.GetByID(ctx, tenantID, id)
if err != nil {
return nil, fmt.Errorf("failed to get MCP service: %w", err)
}
if existing == nil {
return nil, fmt.Errorf("MCP service not found")
}
if existing.IsBuiltin {
return nil, fmt.Errorf("builtin MCP services cannot have credentials modified")
}
if existing.AuthConfig == nil {
existing.AuthConfig = &types.MCPAuthConfig{}
}
changed := false
if apiKey != nil && *apiKey != "" && *apiKey != existing.AuthConfig.APIKey {
existing.AuthConfig.APIKey = *apiKey
changed = true
}
if token != nil && *token != "" && *token != existing.AuthConfig.Token {
existing.AuthConfig.Token = *token
changed = true
}
if !changed {
return existing, nil
}
existing.UpdatedAt = time.Now()
if err := s.mcpServiceRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update MCP service: %w", err)
}
// Credential changed → recycle client so the next call reconnects.
s.mcpManager.CloseClient(id)
logger.GetLogger(ctx).Infof(
"MCP credentials updated, connection closed: %s (ID: %s)",
secutils.SanitizeForLog(existing.Name), id,
)
return existing, nil
}
// ClearMCPCredential removes a single credential field. Idempotent: clearing
// an already-empty field returns nil without writing or reconnecting.
func (s *mcpServiceService) ClearMCPCredential(
ctx context.Context, tenantID uint64, id, field string,
) error {
existing, err := s.mcpServiceRepo.GetByID(ctx, tenantID, id)
if err != nil {
return fmt.Errorf("failed to get MCP service: %w", err)
}
if existing == nil {
return fmt.Errorf("MCP service not found")
}
if existing.IsBuiltin {
return fmt.Errorf("builtin MCP services cannot have credentials modified")
}
if existing.AuthConfig == nil {
return nil // nothing to clear
}
changed := false
switch field {
case "api_key":
if existing.AuthConfig.APIKey != "" {
existing.AuthConfig.APIKey = ""
changed = true
}
case "token":
if existing.AuthConfig.Token != "" {
existing.AuthConfig.Token = ""
changed = true
}
default:
return fmt.Errorf("unknown credential field: %s", field)
}
if !changed {
return nil
}
existing.UpdatedAt = time.Now()
if err := s.mcpServiceRepo.Update(ctx, existing); err != nil {
return fmt.Errorf("failed to update MCP service: %w", err)
}
s.mcpManager.CloseClient(id)
logger.GetLogger(ctx).Infof(
"MCP credential cleared by user: id=%s field=%s, connection closed",
secutils.SanitizeForLog(id), field,
)
return nil
}
// GetMCPServiceResources retrieves the list of resources from an MCP service
func (s *mcpServiceService) GetMCPServiceResources(
ctx context.Context,

View File

@@ -2,6 +2,7 @@ package service
import (
"context"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/mcp"
@@ -11,7 +12,7 @@ import (
)
// fakeMCPRepo is a minimal in-memory implementation of
// interfaces.MCPServiceRepository for testing the service-layer merge logic
// interfaces.MCPServiceRepository for testing the service-layer logic
// without depending on the database.
type fakeMCPRepo struct {
store map[string]*types.MCPService
@@ -22,7 +23,7 @@ func newFakeMCPRepo() *fakeMCPRepo {
}
func (r *fakeMCPRepo) Create(_ context.Context, s *types.MCPService) error {
r.store[s.ID] = s
r.store[s.ID] = cloneService(s)
return nil
}
@@ -31,25 +32,21 @@ func (r *fakeMCPRepo) GetByID(_ context.Context, _ uint64, id string) (*types.MC
if !ok {
return nil, nil
}
// return a deep-ish copy so service-layer mutations to the returned value
// do not leak back into the fake store, mirroring how GORM produces a
// fresh struct per query
cp := *s
if s.AuthConfig != nil {
ac := *s.AuthConfig
cp.AuthConfig = &ac
}
if s.AdvancedConfig != nil {
adv := *s.AdvancedConfig
cp.AdvancedConfig = &adv
}
return &cp, nil
// Return a copy so service-layer mutations don't leak back into the
// store, mirroring how GORM returns a fresh struct per query.
return cloneService(s), nil
}
func cloneService(s *types.MCPService) *types.MCPService {
cp := *s
if s.AuthConfig != nil {
ac := *s.AuthConfig
if s.AuthConfig.CustomHeaders != nil {
ac.CustomHeaders = make(map[string]string, len(s.AuthConfig.CustomHeaders))
for k, v := range s.AuthConfig.CustomHeaders {
ac.CustomHeaders[k] = v
}
}
cp.AuthConfig = &ac
}
if s.AdvancedConfig != nil {
@@ -82,7 +79,7 @@ func (r *fakeMCPRepo) ListByIDs(_ context.Context, _ uint64, ids []string) ([]*t
}
func (r *fakeMCPRepo) Update(_ context.Context, s *types.MCPService) error {
r.store[s.ID] = s
r.store[s.ID] = cloneService(s)
return nil
}
@@ -91,8 +88,6 @@ func (r *fakeMCPRepo) Delete(_ context.Context, _ uint64, id string) error {
return nil
}
// seedService plants a service with the given auth credentials into the fake
// repo and returns the service ID. Convenience helper used by every test.
func seedService(t *testing.T, repo *fakeMCPRepo, apiKey, token string) string {
t.Helper()
s := &types.MCPService{
@@ -111,8 +106,7 @@ func seedService(t *testing.T, repo *fakeMCPRepo, apiKey, token string) string {
}
// newTestService wires up a mcpServiceService with a fresh fake repo and a
// real (empty) MCPManager. CloseClient on an empty manager is a no-op, so
// tests can safely exercise the configChanged code paths.
// real (empty) MCPManager. CloseClient on an empty manager is a no-op.
func newTestService() (*mcpServiceService, *fakeMCPRepo) {
repo := newFakeMCPRepo()
svc := &mcpServiceService{
@@ -122,253 +116,54 @@ func newTestService() (*mcpServiceService, *fakeMCPRepo) {
return svc, repo
}
// buildFullUpdate returns an UpdateMCPService input that exercises the
// "full update" branch (service.Name is non-empty), which is the branch that
// actually merges AuthConfig.
func buildFullUpdate(id string, auth *types.MCPAuthConfig) *types.MCPService {
return &types.MCPService{
// ---- UpdateMCPService: must not touch APIKey/Token even when caller sends them ----
// The handler now strips api_key/token from the main PUT body, but defense
// in depth: if a future caller (CLI / test / misconfigured proxy) still
// passes auth_config with secret fields, UpdateMCPService must NOT clobber
// the stored credentials. Credentials live behind the dedicated subresource.
func TestUpdateMCPService_DoesNotTouchSecretsEvenIfPassed(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := &types.MCPService{
ID: id,
TenantID: 1,
Name: "test", // non-empty → full update branch
Name: "renamed",
Enabled: true,
TransportType: types.MCPTransportSSE,
AuthConfig: auth,
}
}
func TestUpdateMCPService_PreservesSecretsOnEmptyOrRedacted(t *testing.T) {
ctx := context.Background()
t.Run("nil AuthConfig preserves existing", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, nil)
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey)
assert.Equal(t, "stored-token", got.AuthConfig.Token)
})
t.Run("empty string preserves existing", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{APIKey: "", Token: ""})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey)
assert.Equal(t, "stored-token", got.AuthConfig.Token)
})
t.Run("redacted placeholder preserves existing (NELO-1299 round-trip)", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{
APIKey: types.RedactedSecretPlaceholder,
Token: types.RedactedSecretPlaceholder,
})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey)
assert.Equal(t, "stored-token", got.AuthConfig.Token)
})
}
func TestUpdateMCPService_ReplacesOnExplicitValue(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{
APIKey: "new-api",
Token: "new-token",
})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "new-api", got.AuthConfig.APIKey)
assert.Equal(t, "new-token", got.AuthConfig.Token)
}
func TestUpdateMCPService_MixedFields(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
// Replace token only, leave api_key as redacted placeholder → preserve
upd := buildFullUpdate(id, &types.MCPAuthConfig{
APIKey: types.RedactedSecretPlaceholder,
Token: "new-token",
})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey, "api_key should be preserved")
assert.Equal(t, "new-token", got.AuthConfig.Token, "token should be replaced")
}
func TestUpdateMCPService_ClearFlags(t *testing.T) {
ctx := context.Background()
t.Run("ClearToken removes stored token, preserves api_key", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{
APIKey: types.RedactedSecretPlaceholder,
ClearToken: true,
})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey)
assert.Empty(t, got.AuthConfig.Token)
})
t.Run("ClearAPIKey removes stored api_key, preserves token", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{
ClearAPIKey: true,
Token: types.RedactedSecretPlaceholder,
})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Empty(t, got.AuthConfig.APIKey)
assert.Equal(t, "stored-token", got.AuthConfig.Token)
})
t.Run("ClearToken takes precedence over submitted value", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{
Token: "will-be-ignored",
ClearToken: true,
})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Empty(t, got.AuthConfig.Token)
})
t.Run("clear flags are not persisted", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
upd := buildFullUpdate(id, &types.MCPAuthConfig{ClearToken: true})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
require.NotNil(t, got.AuthConfig)
assert.False(t, got.AuthConfig.ClearToken, "ClearToken must not be persisted")
assert.False(t, got.AuthConfig.ClearAPIKey, "ClearAPIKey must not be persisted")
})
t.Run("clear on already-empty field is a no-op", func(t *testing.T) {
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "") // token not set
upd := buildFullUpdate(id, &types.MCPAuthConfig{ClearToken: true})
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey)
assert.Empty(t, got.AuthConfig.Token)
})
}
func TestListMCPServices_RedactsSecrets(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
seedService(t, repo, "real-api", "real-token")
got, err := svc.ListMCPServices(ctx, 1)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, types.RedactedSecretPlaceholder, got[0].AuthConfig.APIKey)
assert.Equal(t, types.RedactedSecretPlaceholder, got[0].AuthConfig.Token)
// And the underlying store is untouched
stored := repo.store["svc-test"]
assert.Equal(t, "real-api", stored.AuthConfig.APIKey)
assert.Equal(t, "real-token", stored.AuthConfig.Token)
}
func TestGetMCPServiceByID_RedactsSecrets(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "real-api", "real-token")
got, err := svc.GetMCPServiceByID(ctx, 1, id)
require.NoError(t, err)
require.NotNil(t, got.AuthConfig)
assert.Equal(t, types.RedactedSecretPlaceholder, got.AuthConfig.APIKey)
assert.Equal(t, types.RedactedSecretPlaceholder, got.AuthConfig.Token)
// underlying store unchanged
stored := repo.store[id]
assert.Equal(t, "real-api", stored.AuthConfig.APIKey)
assert.Equal(t, "real-token", stored.AuthConfig.Token)
}
func TestGetMCPServiceByID_EmptySecretsStayEmpty(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "", "") // both empty
got, err := svc.GetMCPServiceByID(ctx, 1, id)
require.NoError(t, err)
require.NotNil(t, got.AuthConfig)
assert.Empty(t, got.AuthConfig.APIKey, "empty secret must stay empty, not be redacted")
assert.Empty(t, got.AuthConfig.Token)
}
// Partial update: service.Name == "" is interpreted as "only touch enabled".
// A clear flag sent in such a body was previously dropped; the refactor now
// merges AuthConfig unconditionally so single-field clears round-trip.
func TestUpdateMCPService_PartialUpdateHonorsClearFlag(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
// Partial update body: no name, enabled flipped, ClearToken set.
upd := &types.MCPService{
ID: id,
TenantID: 1,
Enabled: true, // keep enabled, but simulate a "flag only" request
// Hostile body: tries to overwrite both secrets via main PUT.
AuthConfig: &types.MCPAuthConfig{
ClearToken: true,
APIKey: "should-not-overwrite",
Token: "should-not-overwrite-either",
},
}
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, "stored-api", got.AuthConfig.APIKey, "APIKey preserved in partial update")
assert.Empty(t, got.AuthConfig.Token, "ClearToken must clear Token even in partial update")
assert.Equal(t, "stored-api", got.AuthConfig.APIKey,
"main PUT must not overwrite stored APIKey under any circumstance")
assert.Equal(t, "stored-token", got.AuthConfig.Token,
"main PUT must not overwrite stored Token under any circumstance")
assert.Equal(t, "renamed", got.Name, "non-secret field updates still apply")
}
// CustomHeaders: nil means "no change" (preserve existing). Sending a
// non-nil (including empty) map replaces. Previously the merge always
// overwrote, silently wiping headers on any auth_config-bearing request.
func TestUpdateMCPService_CustomHeadersPreserveOnNil(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
// Seed with custom headers.
repo.store[id].AuthConfig.CustomHeaders = map[string]string{"X-Tenant": "acme"}
// Update with AuthConfig but nil CustomHeaders → preserve.
upd := buildFullUpdate(id, &types.MCPAuthConfig{
Token: "new-token",
})
upd := &types.MCPService{
ID: id,
TenantID: 1,
Name: "test",
Enabled: true,
TransportType: types.MCPTransportSSE,
// AuthConfig present but CustomHeaders nil → preserve.
AuthConfig: &types.MCPAuthConfig{},
}
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
@@ -382,14 +177,181 @@ func TestUpdateMCPService_CustomHeadersReplaceOnNonNil(t *testing.T) {
id := seedService(t, repo, "stored-api", "stored-token")
repo.store[id].AuthConfig.CustomHeaders = map[string]string{"X-Tenant": "acme"}
upd := buildFullUpdate(id, &types.MCPAuthConfig{
APIKey: types.RedactedSecretPlaceholder,
Token: types.RedactedSecretPlaceholder,
CustomHeaders: map[string]string{"X-Replaced": "yes"},
})
upd := &types.MCPService{
ID: id,
TenantID: 1,
Name: "test",
Enabled: true,
TransportType: types.MCPTransportSSE,
AuthConfig: &types.MCPAuthConfig{
CustomHeaders: map[string]string{"X-Replaced": "yes"},
},
}
require.NoError(t, svc.UpdateMCPService(ctx, upd))
got := repo.store[id]
assert.Equal(t, map[string]string{"X-Replaced": "yes"}, got.AuthConfig.CustomHeaders,
"non-nil CustomHeaders must replace the stored map")
}
// ---- UpdateMCPCredentials: write path ----
func TestUpdateMCPCredentials_WritesAPIKey(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "", "")
newKey := "fresh-api-key"
got, err := svc.UpdateMCPCredentials(ctx, 1, id, &newKey, nil)
require.NoError(t, err)
require.NotNil(t, got.AuthConfig)
assert.Equal(t, "fresh-api-key", got.AuthConfig.APIKey)
assert.Empty(t, got.AuthConfig.Token, "untouched field stays untouched")
stored := repo.store[id]
assert.Equal(t, "fresh-api-key", stored.AuthConfig.APIKey, "persisted")
}
func TestUpdateMCPCredentials_NilPointerIsNoop(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
got, err := svc.UpdateMCPCredentials(ctx, 1, id, nil, nil)
require.NoError(t, err)
assert.Equal(t, "stored-api", got.AuthConfig.APIKey)
assert.Equal(t, "stored-token", got.AuthConfig.Token)
}
func TestUpdateMCPCredentials_EmptyStringIsNoop(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
empty := ""
got, err := svc.UpdateMCPCredentials(ctx, 1, id, &empty, &empty)
require.NoError(t, err)
assert.Equal(t, "stored-api", got.AuthConfig.APIKey,
"empty string is treated as no-op; clearing goes through ClearMCPCredential")
assert.Equal(t, "stored-token", got.AuthConfig.Token)
}
func TestUpdateMCPCredentials_ReplacesExisting(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "old-api", "old-token")
newKey, newTok := "new-api", "new-tok"
got, err := svc.UpdateMCPCredentials(ctx, 1, id, &newKey, &newTok)
require.NoError(t, err)
assert.Equal(t, "new-api", got.AuthConfig.APIKey)
assert.Equal(t, "new-tok", got.AuthConfig.Token)
}
func TestUpdateMCPCredentials_RejectsBuiltin(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "")
repo.store[id].IsBuiltin = true
newKey := "anything"
_, err := svc.UpdateMCPCredentials(ctx, 1, id, &newKey, nil)
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "builtin")
}
func TestUpdateMCPCredentials_ServiceNotFound(t *testing.T) {
ctx := context.Background()
svc, _ := newTestService()
newKey := "x"
_, err := svc.UpdateMCPCredentials(ctx, 1, "nope", &newKey, nil)
require.Error(t, err)
}
// ---- ClearMCPCredential ----
func TestClearMCPCredential_ClearsAPIKey(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
require.NoError(t, svc.ClearMCPCredential(ctx, 1, id, "api_key"))
stored := repo.store[id]
assert.Empty(t, stored.AuthConfig.APIKey)
assert.Equal(t, "stored-token", stored.AuthConfig.Token, "other field untouched")
}
func TestClearMCPCredential_ClearsToken(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "stored-token")
require.NoError(t, svc.ClearMCPCredential(ctx, 1, id, "token"))
stored := repo.store[id]
assert.Equal(t, "stored-api", stored.AuthConfig.APIKey)
assert.Empty(t, stored.AuthConfig.Token)
}
func TestClearMCPCredential_IdempotentOnEmpty(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "") // token already empty
require.NoError(t, svc.ClearMCPCredential(ctx, 1, id, "token"),
"clearing already-empty field must not error")
stored := repo.store[id]
assert.Equal(t, "stored-api", stored.AuthConfig.APIKey)
assert.Empty(t, stored.AuthConfig.Token)
}
func TestClearMCPCredential_UnknownFieldErrors(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "")
err := svc.ClearMCPCredential(ctx, 1, id, "bogus")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown")
}
func TestClearMCPCredential_RejectsBuiltin(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "stored-api", "")
repo.store[id].IsBuiltin = true
err := svc.ClearMCPCredential(ctx, 1, id, "api_key")
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "builtin")
}
// ---- Service-layer Get/List: returns RAW entity (DTO handles redaction) ----
// After the credential-subresource refactor, the service-layer Get/List
// return the entity unmodified. Handlers MUST convert via
// dto.NewMCPServiceResponse, but the credentials handler depends on the
// unredacted form to derive metadata (configured: bool).
func TestGetMCPServiceByID_ReturnsRawCredentials(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
id := seedService(t, repo, "real-api", "real-token")
got, err := svc.GetMCPServiceByID(ctx, 1, id)
require.NoError(t, err)
require.NotNil(t, got.AuthConfig)
assert.Equal(t, "real-api", got.AuthConfig.APIKey,
"service layer returns raw credentials; redaction is the DTO's job")
assert.Equal(t, "real-token", got.AuthConfig.Token)
}
func TestListMCPServices_ReturnsRawCredentials(t *testing.T) {
ctx := context.Background()
svc, repo := newTestService()
seedService(t, repo, "real-api", "real-token")
got, err := svc.ListMCPServices(ctx, 1)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "real-api", got[0].AuthConfig.APIKey)
assert.Equal(t, "real-token", got[0].AuthConfig.Token)
}

View File

@@ -247,6 +247,84 @@ func (s *modelService) UpdateModel(ctx context.Context, model *types.Model) erro
return nil
}
// UpdateModelCredentials writes one or more credential fields on the model's
// Parameters jsonb. Models are not pooled per-instance the way MCP clients
// are (each call to GetEmbeddingModel/GetChatModel rebuilds the client from
// the current Parameters), so no explicit cache invalidation is required —
// the next call will pick up the new credential automatically.
func (s *modelService) UpdateModelCredentials(
ctx context.Context, id string, apiKey, appSecret *string,
) (*types.Model, error) {
tenantID := types.MustTenantIDFromContext(ctx)
existing, err := s.repo.GetByID(ctx, tenantID, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, ErrModelNotFound
}
if existing.IsBuiltin {
return nil, errors.New("builtin models cannot have credentials modified")
}
changed := false
if apiKey != nil && *apiKey != "" && *apiKey != existing.Parameters.APIKey {
existing.Parameters.APIKey = *apiKey
changed = true
}
if appSecret != nil && *appSecret != "" && *appSecret != existing.Parameters.AppSecret {
existing.Parameters.AppSecret = *appSecret
changed = true
}
if !changed {
return existing, nil
}
if err := s.repo.Update(ctx, existing); err != nil {
return nil, err
}
logger.Infof(ctx, "Model credentials updated: id=%s", id)
return existing, nil
}
// ClearModelCredential removes a single credential field. Idempotent.
func (s *modelService) ClearModelCredential(ctx context.Context, id, field string) error {
tenantID := types.MustTenantIDFromContext(ctx)
existing, err := s.repo.GetByID(ctx, tenantID, id)
if err != nil {
return err
}
if existing == nil {
return ErrModelNotFound
}
if existing.IsBuiltin {
return errors.New("builtin models cannot have credentials modified")
}
changed := false
switch field {
case "api_key":
if existing.Parameters.APIKey != "" {
existing.Parameters.APIKey = ""
changed = true
}
case "app_secret":
if existing.Parameters.AppSecret != "" {
existing.Parameters.AppSecret = ""
changed = true
}
default:
return errors.New("unknown credential field: " + field)
}
if !changed {
return nil
}
if err := s.repo.Update(ctx, existing); err != nil {
return err
}
logger.Infof(ctx, "Model credential cleared by user: id=%s field=%s", id, field)
return nil
}
// DeleteModel removes a model from the repository
func (s *modelService) DeleteModel(ctx context.Context, id string) error {
logger.Info(ctx, "Start deleting model")

View File

@@ -71,6 +71,16 @@ func (s *stubModelService) DeleteModel(context.Context, string) error {
return nil
}
func (s *stubModelService) UpdateModelCredentials(
context.Context, string, *string, *string,
) (*types.Model, error) {
return nil, nil
}
func (s *stubModelService) ClearModelCredential(context.Context, string, string) error {
return nil
}
func (s *stubModelService) GetEmbeddingModel(context.Context, string) (embedding.Embedder, error) {
return nil, nil
}

View File

@@ -71,6 +71,55 @@ func (s *webSearchProviderService) UpdateProvider(ctx context.Context, provider
return s.repo.Update(ctx, provider)
}
// UpdateProviderCredentials writes the api_key credential field. Web search
// providers are stateless from our side — every search call rebuilds a
// transport from current Parameters — so no cache invalidation is required.
func (s *webSearchProviderService) UpdateProviderCredentials(
ctx context.Context, tenantID uint64, id string, apiKey *string,
) (*types.WebSearchProviderEntity, error) {
existing, err := s.repo.GetByID(ctx, tenantID, id)
if err != nil {
return nil, err
}
if existing == nil {
return nil, fmt.Errorf("web search provider not found")
}
if apiKey != nil && *apiKey != "" && *apiKey != existing.Parameters.APIKey {
existing.Parameters.APIKey = *apiKey
if err := s.repo.Update(ctx, existing); err != nil {
return nil, err
}
logger.Infof(ctx, "WebSearch provider credentials updated: tenant=%d id=%s", tenantID, id)
}
return existing, nil
}
// ClearProviderCredential clears the api_key credential. Idempotent.
func (s *webSearchProviderService) ClearProviderCredential(
ctx context.Context, tenantID uint64, id, field string,
) error {
if field != "api_key" {
return fmt.Errorf("unknown credential field: %s", field)
}
existing, err := s.repo.GetByID(ctx, tenantID, id)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("web search provider not found")
}
if existing.Parameters.APIKey == "" {
return nil
}
existing.Parameters.APIKey = ""
if err := s.repo.Update(ctx, existing); err != nil {
return err
}
logger.Infof(ctx, "WebSearch provider credential cleared by user: tenant=%d id=%s field=%s", tenantID, id, field)
return nil
}
// DeleteProvider deletes a provider by tenant + id.
func (s *webSearchProviderService) DeleteProvider(ctx context.Context, tenantID uint64, id string) error {
logger.Infof(ctx, "Deleting web search provider: tenant=%d, id=%s", tenantID, id)

View File

@@ -293,6 +293,10 @@ func BuildContainer(container *dig.Container) *dig.Container {
must(container.Provide(handler.NewAuthHandler))
must(container.Provide(handler.NewSystemHandler))
must(container.Provide(handler.NewMCPServiceHandler))
must(container.Provide(handler.NewMCPCredentialsHandler))
must(container.Provide(handler.NewModelCredentialsHandler))
must(container.Provide(handler.NewWebSearchProviderCredentialsHandler))
must(container.Provide(handler.NewDataSourceCredentialsHandler))
must(container.Provide(handler.NewWebSearchHandler))
must(container.Provide(handler.NewWebSearchProviderHandler))
must(container.Provide(handler.NewVectorStoreHandler))

View File

@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/Tencent/WeKnora/internal/datasource"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
@@ -114,8 +115,7 @@ func (h *DataSourceHandler) CreateDataSource(c *gin.Context) {
return
}
ds.RedactSensitiveData()
c.JSON(http.StatusCreated, ds)
c.JSON(http.StatusCreated, dto.NewDataSourceResponse(ds))
}
// GetDataSource godoc
@@ -143,8 +143,7 @@ func (h *DataSourceHandler) GetDataSource(c *gin.Context) {
return
}
ds.RedactSensitiveData()
c.JSON(http.StatusOK, ds)
c.JSON(http.StatusOK, dto.NewDataSourceResponse(ds))
}
// ListDataSources godoc
@@ -181,10 +180,7 @@ func (h *DataSourceHandler) ListDataSources(c *gin.Context) {
if dataSources == nil {
dataSources = make([]*types.DataSource, 0)
}
for _, ds := range dataSources {
ds.RedactSensitiveData()
}
c.JSON(http.StatusOK, dataSources)
c.JSON(http.StatusOK, dto.NewDataSourceResponses(dataSources))
}
// UpdateDataSource godoc
@@ -229,8 +225,7 @@ func (h *DataSourceHandler) UpdateDataSource(c *gin.Context) {
return
}
ds.RedactSensitiveData()
c.JSON(http.StatusOK, ds)
c.JSON(http.StatusOK, dto.NewDataSourceResponse(ds))
}
// DeleteDataSource godoc

View File

@@ -0,0 +1,115 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// DataSourceCredentialsHandler handles credentials for data source
// connectors via the dedicated /credentials subresource.
//
// Unlike the other three resources (MCP / Model / WebSearch), DataSource
// credentials are a per-connector atomic map — there's no individual-field
// PUT or DELETE because half-configured connector auth doesn't work. So we
// expose a single logical field "credentials": GET returns whether anything
// is stored, PUT replaces the whole map, DELETE wipes it.
type DataSourceCredentialsHandler struct {
service interfaces.DataSourceService
kbService interfaces.KnowledgeBaseService
}
func NewDataSourceCredentialsHandler(
service interfaces.DataSourceService,
kbService interfaces.KnowledgeBaseService,
) *DataSourceCredentialsHandler {
return &DataSourceCredentialsHandler{service: service, kbService: kbService}
}
// ownDataSource is the same tenant-isolation check used in datasource.go,
// duplicated here to avoid coupling the two handlers via internal helpers.
func (h *DataSourceCredentialsHandler) ownDataSource(c *gin.Context) (*types.DataSource, bool) {
ctx := c.Request.Context()
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return nil, false
}
id := c.Param("id")
ds, err := h.service.GetDataSource(ctx, id)
if err != nil || ds == nil {
c.Error(errors.NewNotFoundError("data source not found"))
return nil, false
}
kb, err := h.kbService.GetKnowledgeBaseByID(ctx, ds.KnowledgeBaseID)
if err != nil || kb == nil || kb.TenantID != tenantID {
c.Error(errors.NewNotFoundError("data source not found"))
return nil, false
}
return ds, true
}
type dataSourceCredentialsPutRequest struct {
Credentials map[string]interface{} `json:"credentials" binding:"required"`
}
func (h *DataSourceCredentialsHandler) Put(c *gin.Context) {
ds, ok := h.ownDataSource(c)
if !ok {
return
}
var req dataSourceCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if len(req.Credentials) == 0 {
c.Error(errors.NewBadRequestError(
"credentials map must be non-empty; to remove credentials use DELETE /credentials/credentials"))
return
}
updated, err := h.service.UpdateDataSourceCredentials(c.Request.Context(), ds.ID, req.Credentials)
if err != nil {
logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{
"data_source_id": secutils.SanitizeForLog(ds.ID),
})
c.Error(errors.NewBadRequestError("failed to update credentials: " + err.Error()))
return
}
configured := false
if parsed, err := updated.ParseConfig(); err == nil && parsed != nil && parsed.HasCredentials() {
configured = true
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"credentials": {Configured: configured},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
func (h *DataSourceCredentialsHandler) DeleteField(c *gin.Context) {
ds, ok := h.ownDataSource(c)
if !ok {
return
}
field := c.Param("field")
if field != "credentials" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.service.ClearDataSourceCredentials(c.Request.Context(), ds.ID); err != nil {
logger.ErrorWithFields(c.Request.Context(), err, map[string]interface{}{
"data_source_id": secutils.SanitizeForLog(ds.ID),
})
c.Error(errors.NewInternalServerError("failed to clear credentials: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -0,0 +1,103 @@
package dto
import (
"encoding/json"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// DataSourceResponse mirrors types.DataSource for response bodies, with the
// connector Credentials map stripped from the Config jsonb. Credential
// presence is exposed via the dedicated /credentials subresource.
//
// Unlike MCP / Model / WebSearch (which have a flat set of named credential
// fields), DataSource credentials are a per-connector atomic map — an OAuth
// token pair, a Confluence email+token bundle, etc. Splitting them at the
// field level would leave half-configured states that can't actually
// authenticate. The subresource therefore exposes only one logical field,
// "credentials", with PUT replacing the whole map and DELETE wiping it.
type DataSourceResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Name string `json:"name"`
Type string `json:"type"`
Config *DataSourceConfigDTO `json:"config,omitempty"`
SyncSchedule string `json:"sync_schedule"`
SyncMode string `json:"sync_mode"`
Status string `json:"status"`
ConflictStrategy string `json:"conflict_strategy"`
SyncDeletions bool `json:"sync_deletions"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastSyncCursor json.RawMessage `json:"last_sync_cursor,omitempty"`
LastSyncResult json.RawMessage `json:"last_sync_result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
SyncLogRetentionDays int `json:"sync_log_retention_days"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TotalItemsSynced int64 `json:"total_items_synced"`
LatestSyncLog *types.SyncLog `json:"latest_sync_log,omitempty"`
// Single logical credential field — DataSource credentials are a
// per-connector atomic map, so "configured?" applies to the whole set.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// DataSourceConfigDTO is types.DataSourceConfig with the Credentials map
// removed by construction. Type, ResourceIDs and Settings remain visible.
type DataSourceConfigDTO struct {
Type string `json:"type"`
ResourceIDs []string `json:"resource_ids,omitempty"`
Settings map[string]interface{} `json:"settings,omitempty"`
}
// NewDataSourceResponse converts a stored entity into its response shape.
// Returns nil for nil input.
func NewDataSourceResponse(ds *types.DataSource) *DataSourceResponse {
if ds == nil {
return nil
}
var cfgDTO *DataSourceConfigDTO
configured := false
if parsed, err := ds.ParseConfig(); err == nil && parsed != nil {
cfgDTO = &DataSourceConfigDTO{
Type: parsed.Type,
ResourceIDs: parsed.ResourceIDs,
Settings: parsed.Settings,
}
configured = parsed.HasCredentials()
}
return &DataSourceResponse{
ID: ds.ID,
TenantID: ds.TenantID,
KnowledgeBaseID: ds.KnowledgeBaseID,
Name: ds.Name,
Type: ds.Type,
Config: cfgDTO,
SyncSchedule: ds.SyncSchedule,
SyncMode: ds.SyncMode,
Status: ds.Status,
ConflictStrategy: ds.ConflictStrategy,
SyncDeletions: ds.SyncDeletions,
LastSyncAt: ds.LastSyncAt,
LastSyncCursor: json.RawMessage(ds.LastSyncCursor),
LastSyncResult: json.RawMessage(ds.LastSyncResult),
ErrorMessage: ds.ErrorMessage,
SyncLogRetentionDays: ds.SyncLogRetentionDays,
CreatedAt: ds.CreatedAt,
UpdatedAt: ds.UpdatedAt,
TotalItemsSynced: ds.TotalItemsSynced,
LatestSyncLog: ds.LatestSyncLog,
Credentials: map[string]CredentialFieldMetadata{
"credentials": {Configured: configured},
},
}
}
func NewDataSourceResponses(dss []*types.DataSource) []*DataSourceResponse {
out := make([]*DataSourceResponse, 0, len(dss))
for _, d := range dss {
out = append(out, NewDataSourceResponse(d))
}
return out
}

View File

@@ -0,0 +1,62 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestDataSourceResponse_OmitsCredentials(t *testing.T) {
cfg := types.DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"token": "ghp-secret-do-not-leak",
},
ResourceIDs: []string{"repo-1"},
Settings: map[string]interface{}{"branch": "main"},
}
blob, _ := cfg.ToJSON()
ds := &types.DataSource{
ID: "ds-1",
Name: "github-prod",
Type: "github",
Config: blob,
}
body, err := json.Marshal(NewDataSourceResponse(ds))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "ghp-secret-do-not-leak")
// The inner config object must not carry the credentials map (the
// DataSourceConfigDTO type omits it structurally).
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
if cfgRaw, ok := raw["config"]; ok {
var inner map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(cfgRaw, &inner))
_, hasCredsInConfig := inner["credentials"]
assert.False(t, hasCredsInConfig,
"credentials map must not appear inside the config DTO")
}
// Top-level credentials map is just the "configured?" indicator,
// replaces the removed GET /credentials endpoint.
assert.Contains(t, s, `"credentials":{"credentials":{"configured":true}}`)
// Non-secret config fields pass through.
assert.Contains(t, s, "repo-1")
assert.Contains(t, s, "branch")
assert.Contains(t, s, "main")
}
func TestDataSourceResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewDataSourceResponse(nil))
assert.Equal(t, []*DataSourceResponse{}, NewDataSourceResponses(nil))
}
func TestDataSourceResponse_NoConfig(t *testing.T) {
ds := &types.DataSource{ID: "x", Name: "x"}
body, err := json.Marshal(NewDataSourceResponse(ds))
assert.NoError(t, err)
// No config jsonb stored → no config object in the response.
assert.NotContains(t, string(body), `"config":`)
}

122
internal/handler/dto/mcp.go Normal file
View File

@@ -0,0 +1,122 @@
// Package dto holds response shapes for handler responses that need to differ
// from the persisted GORM model — most notably, response types that must NOT
// carry secret fields.
//
// Why a separate package: response DTOs are deliberately distinct from the
// internal model so the "no secret in responses" guarantee is a compile-time
// invariant rather than a runtime redaction step. If a future contributor
// wants to expose a credential in a response, they must add it to the DTO
// explicitly, which makes the leak surface review-able in a single diff.
package dto
import (
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPServiceResponse mirrors types.MCPService for response bodies, omitting
// every secret field (api_key, token). Credential presence/absence is exposed
// separately via the /credentials subresource endpoint; this shape carries
// only a boolean per credential field so the frontend can render a
// "configured / not configured" badge without an additional round-trip.
type MCPServiceResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
TransportType types.MCPTransportType `json:"transport_type"`
URL *string `json:"url,omitempty"`
Headers types.MCPHeaders `json:"headers,omitempty"`
AuthConfig *MCPAuthConfigResponse `json:"auth_config,omitempty"`
AdvancedConfig *types.MCPAdvancedConfig `json:"advanced_config,omitempty"`
StdioConfig *types.MCPStdioConfig `json:"stdio_config,omitempty"`
EnvVars types.MCPEnvVars `json:"env_vars,omitempty"`
IsBuiltin bool `json:"is_builtin"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Credentials is the per-field "configured?" map. Embedded on the main
// response so the credential UI doesn't need a follow-up GET. The
// frontend never sees the actual secret value — only whether one is
// stored. Omitted entirely for builtin services (they can't have
// per-tenant credentials).
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// MCPAuthConfigResponse intentionally has no APIKey or Token fields. Their
// presence is signalled via MCPServiceResponse.Credentials.
type MCPAuthConfigResponse struct {
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
}
// CredentialFieldMetadata reports whether a credential field has a value
// stored server-side, without exposing the value itself.
type CredentialFieldMetadata struct {
Configured bool `json:"configured"`
}
// NewMCPServiceResponse converts a stored MCPService into its response shape.
//
// Builtin MCP services have their tenant-specific transport details (URL,
// Headers, EnvVars, StdioConfig) stripped — these reveal how the tenant
// configured an upstream provider and must not be visible to other tenants
// that see the same builtin row via the cross-tenant list.
func NewMCPServiceResponse(svc *types.MCPService) *MCPServiceResponse {
if svc == nil {
return nil
}
resp := &MCPServiceResponse{
ID: svc.ID,
TenantID: svc.TenantID,
Name: svc.Name,
Description: svc.Description,
Enabled: svc.Enabled,
TransportType: svc.TransportType,
URL: svc.URL,
Headers: svc.Headers,
AdvancedConfig: svc.AdvancedConfig,
StdioConfig: svc.StdioConfig,
EnvVars: svc.EnvVars,
IsBuiltin: svc.IsBuiltin,
CreatedAt: svc.CreatedAt,
UpdatedAt: svc.UpdatedAt,
}
if svc.AuthConfig != nil {
resp.AuthConfig = &MCPAuthConfigResponse{
CustomHeaders: svc.AuthConfig.CustomHeaders,
}
}
if svc.IsBuiltin {
// Builtin services are shared across tenants — strip everything that
// could leak how this tenant configured the underlying provider.
resp.URL = nil
resp.Headers = nil
resp.EnvVars = nil
resp.StdioConfig = nil
resp.AuthConfig = nil
} else {
resp.Credentials = map[string]CredentialFieldMetadata{
"api_key": {Configured: svc.AuthConfig != nil && svc.AuthConfig.APIKey != ""},
"token": {Configured: svc.AuthConfig != nil && svc.AuthConfig.Token != ""},
}
}
return resp
}
// NewMCPServiceResponses is the slice convenience wrapper used by ListMCPServices.
func NewMCPServiceResponses(svcs []*types.MCPService) []*MCPServiceResponse {
out := make([]*MCPServiceResponse, 0, len(svcs))
for _, s := range svcs {
out = append(out, NewMCPServiceResponse(s))
}
return out
}
// CredentialsResponse is the shared shape returned by PUT
// /{resource}/{id}/credentials. Keyed by field name (e.g. "api_key",
// "token"). The frontend uses this to update its in-memory metadata after a
// successful save without needing to re-fetch the whole resource.
type CredentialsResponse struct {
Fields map[string]CredentialFieldMetadata `json:"fields"`
}

View File

@@ -0,0 +1,79 @@
package dto
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
// The most important guarantee of the DTO layer is structural: the serialized
// response body must NEVER contain api_key or token under any condition,
// regardless of the underlying entity's state. We assert this via the
// serialized JSON (not just struct shape) because reflection-based
// custom-marshalers anywhere downstream could otherwise reintroduce a leak.
func TestMCPServiceResponse_OmitsSecrets(t *testing.T) {
svc := &types.MCPService{
ID: "svc-1",
Name: "svc",
AuthConfig: &types.MCPAuthConfig{
APIKey: "sk-real-api-key-do-not-leak",
Token: "tok-real-bearer-token-do-not-leak",
CustomHeaders: map[string]string{"X-Trace": "abc"},
},
}
body, err := json.Marshal(NewMCPServiceResponse(svc))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "sk-real-api-key-do-not-leak",
"raw api_key must never appear in MCPServiceResponse")
assert.NotContains(t, s, "tok-real-bearer-token-do-not-leak",
"raw token must never appear in MCPServiceResponse")
// auth_config no longer has api_key/token fields (only custom_headers
// survived). Verify the auth_config sub-object contains no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
if ac, ok := raw["auth_config"]; ok {
acStr := string(ac)
assert.NotContains(t, acStr, `"api_key"`)
assert.NotContains(t, acStr, `"token"`)
}
// The new credentials map exposes "configured?" booleans by design
// (replaces the standalone GET /credentials endpoint). Verify the
// values are booleans, not strings.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
assert.Contains(t, s, `"token":{"configured":true}`)
// CustomHeaders is structural metadata and SHOULD pass through.
assert.Contains(t, s, `"custom_headers"`)
assert.Contains(t, s, `"X-Trace"`)
}
func TestMCPServiceResponse_BuiltinStripsTenantConfig(t *testing.T) {
url := "https://tenant-private.example.com"
svc := &types.MCPService{
ID: "builtin-1",
IsBuiltin: true,
URL: &url,
Headers: types.MCPHeaders{"X-Tenant-Secret": "shhh"},
AuthConfig: &types.MCPAuthConfig{
APIKey: "should-not-leak-via-builtin",
},
}
resp := NewMCPServiceResponse(svc)
assert.Nil(t, resp.URL, "builtin must not leak per-tenant URL")
assert.Nil(t, resp.Headers, "builtin must not leak per-tenant headers")
assert.Nil(t, resp.AuthConfig, "builtin must not leak auth config")
body, _ := json.Marshal(resp)
assert.False(t, strings.Contains(string(body), "should-not-leak-via-builtin"))
assert.False(t, strings.Contains(string(body), "X-Tenant-Secret"))
}
func TestMCPServiceResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewMCPServiceResponse(nil))
assert.Equal(t, []*MCPServiceResponse{}, NewMCPServiceResponses(nil))
}

View File

@@ -0,0 +1,111 @@
package dto
import (
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// ModelResponse mirrors types.Model for response bodies, with all secret
// fields (APIKey, AppSecret) removed by construction. Credential presence
// metadata lives behind the /credentials subresource, not inlined here.
//
// BaseURL is preserved for tenant-owned models (the frontend needs it to
// render which endpoint a custom model points at). For builtin models it is
// stripped along with every other field that could leak how a particular
// tenant configured the upstream provider.
type ModelResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Type types.ModelType `json:"type"`
Source types.ModelSource `json:"source"`
Description string `json:"description"`
Parameters ModelParametersDTO `json:"parameters"`
IsDefault bool `json:"is_default"`
IsBuiltin bool `json:"is_builtin"`
Status types.ModelStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-field "configured?" map. Omitted for builtin models (no
// per-tenant credentials). See MCPServiceResponse.Credentials.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// ModelParametersDTO carries every parameter field EXCEPT the two secret
// ones (APIKey, AppSecret). AppID is non-secret and stays — it's an account
// identifier the WeKnora Cloud frontend renders. CustomHeaders is also kept
// (structural metadata, not a credential).
type ModelParametersDTO struct {
BaseURL string `json:"base_url"`
InterfaceType string `json:"interface_type"`
EmbeddingParameters types.EmbeddingParameters `json:"embedding_parameters"`
ParameterSize string `json:"parameter_size"`
Provider string `json:"provider"`
ExtraConfig map[string]string `json:"extra_config,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
SupportsVision bool `json:"supports_vision"`
AppID string `json:"app_id,omitempty"`
}
// NewModelResponse converts a stored Model into its response shape.
//
// Builtin models are shared across tenants — strip BaseURL (which can leak
// the tenant's private endpoint) and any non-shared parameters.
func NewModelResponse(m *types.Model) *ModelResponse {
if m == nil {
return nil
}
params := ModelParametersDTO{
BaseURL: m.Parameters.BaseURL,
InterfaceType: m.Parameters.InterfaceType,
EmbeddingParameters: m.Parameters.EmbeddingParameters,
ParameterSize: m.Parameters.ParameterSize,
Provider: m.Parameters.Provider,
ExtraConfig: m.Parameters.ExtraConfig,
CustomHeaders: m.Parameters.CustomHeaders,
SupportsVision: m.Parameters.SupportsVision,
AppID: m.Parameters.AppID,
}
if m.IsBuiltin {
// Builtin: strip everything that could reveal per-tenant config.
// EmbeddingParameters and ParameterSize / Provider / InterfaceType /
// SupportsVision are intentionally preserved (they describe the
// capability surface, not the configured endpoint).
params.BaseURL = ""
params.ExtraConfig = nil
params.CustomHeaders = nil
params.AppID = ""
}
var creds map[string]CredentialFieldMetadata
if !m.IsBuiltin {
creds = map[string]CredentialFieldMetadata{
"api_key": {Configured: m.Parameters.APIKey != ""},
"app_secret": {Configured: m.Parameters.AppSecret != ""},
}
}
return &ModelResponse{
ID: m.ID,
TenantID: m.TenantID,
Name: m.Name,
Type: m.Type,
Source: m.Source,
Description: m.Description,
Parameters: params,
IsDefault: m.IsDefault,
IsBuiltin: m.IsBuiltin,
Status: m.Status,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Credentials: creds,
}
}
// NewModelResponses is the slice convenience wrapper.
func NewModelResponses(models []*types.Model) []*ModelResponse {
out := make([]*ModelResponse, 0, len(models))
for _, m := range models {
out = append(out, NewModelResponse(m))
}
return out
}

View File

@@ -0,0 +1,74 @@
package dto
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestModelResponse_OmitsSecrets(t *testing.T) {
m := &types.Model{
ID: "m-1",
Name: "gpt-x",
Parameters: types.ModelParameters{
APIKey: "sk-real-api-key-do-not-leak",
AppSecret: "app-real-secret-do-not-leak",
AppID: "appid-public-ok-to-show",
BaseURL: "https://api.example.com",
Provider: "openai",
},
}
body, err := json.Marshal(NewModelResponse(m))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "sk-real-api-key-do-not-leak")
assert.NotContains(t, s, "app-real-secret-do-not-leak")
// Parameters sub-object must contain no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
params := string(raw["parameters"])
assert.NotContains(t, params, `"api_key"`)
assert.NotContains(t, params, `"app_secret"`)
// Credential metadata map exposes booleans only.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
assert.Contains(t, s, `"app_secret":{"configured":true}`)
// Non-secret fields pass through.
assert.Contains(t, s, "appid-public-ok-to-show")
assert.Contains(t, s, "api.example.com")
}
func TestModelResponse_BuiltinStripsTenantConfig(t *testing.T) {
m := &types.Model{
ID: "builtin-1",
IsBuiltin: true,
Parameters: types.ModelParameters{
BaseURL: "https://tenant-private.example.com",
APIKey: "should-not-leak",
AppID: "tenant-app-id",
SupportsVision: true,
ExtraConfig: map[string]string{"region": "cn-hangzhou"},
},
}
resp := NewModelResponse(m)
assert.Empty(t, resp.Parameters.BaseURL,
"builtin must not leak per-tenant base URL")
assert.Empty(t, resp.Parameters.AppID,
"builtin must not leak per-tenant app_id")
assert.Nil(t, resp.Parameters.ExtraConfig,
"builtin must not leak per-tenant extra_config")
assert.True(t, resp.Parameters.SupportsVision,
"capability metadata must survive (not per-tenant)")
body, _ := json.Marshal(resp)
assert.False(t, strings.Contains(string(body), "should-not-leak"))
assert.False(t, strings.Contains(string(body), "tenant-private.example.com"))
}
func TestModelResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewModelResponse(nil))
assert.Equal(t, []*ModelResponse{}, NewModelResponses(nil))
}

View File

@@ -0,0 +1,68 @@
package dto
import (
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchProviderResponse mirrors types.WebSearchProviderEntity for
// response bodies, with the APIKey field removed by construction. Credential
// presence is exposed via the /credentials subresource.
type WebSearchProviderResponse struct {
ID string `json:"id"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Provider types.WebSearchProviderType `json:"provider"`
Description string `json:"description"`
Parameters WebSearchProviderParametersDTO `json:"parameters"`
IsDefault bool `json:"is_default"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Per-field "configured?" map. See MCPServiceResponse.Credentials.
Credentials map[string]CredentialFieldMetadata `json:"credentials,omitempty"`
}
// WebSearchProviderParametersDTO holds every parameter field except APIKey.
// EngineID, BaseURL, ProxyURL and ExtraConfig are not secrets (they describe
// where to send the request, not how to authenticate) and remain visible.
type WebSearchProviderParametersDTO struct {
EngineID string `json:"engine_id,omitempty"`
BaseURL string `json:"base_url,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
ExtraConfig map[string]string `json:"extra_config,omitempty"`
}
// NewWebSearchProviderResponse converts a stored entity into its response shape.
func NewWebSearchProviderResponse(e *types.WebSearchProviderEntity) *WebSearchProviderResponse {
if e == nil {
return nil
}
return &WebSearchProviderResponse{
ID: e.ID,
TenantID: e.TenantID,
Name: e.Name,
Provider: e.Provider,
Description: e.Description,
Parameters: WebSearchProviderParametersDTO{
EngineID: e.Parameters.EngineID,
BaseURL: e.Parameters.BaseURL,
ProxyURL: e.Parameters.ProxyURL,
ExtraConfig: e.Parameters.ExtraConfig,
},
IsDefault: e.IsDefault,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Credentials: map[string]CredentialFieldMetadata{
"api_key": {Configured: e.Parameters.APIKey != ""},
},
}
}
func NewWebSearchProviderResponses(es []*types.WebSearchProviderEntity) []*WebSearchProviderResponse {
out := make([]*WebSearchProviderResponse, 0, len(es))
for _, e := range es {
out = append(out, NewWebSearchProviderResponse(e))
}
return out
}

View File

@@ -0,0 +1,41 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
)
func TestWebSearchProviderResponse_OmitsSecrets(t *testing.T) {
e := &types.WebSearchProviderEntity{
ID: "wsp-1",
Name: "bing prod",
Provider: types.WebSearchProviderTypeBing,
Parameters: types.WebSearchProviderParameters{
APIKey: "bing-secret-do-not-leak",
EngineID: "engine-public-id",
BaseURL: "https://example.com",
},
}
body, err := json.Marshal(NewWebSearchProviderResponse(e))
assert.NoError(t, err)
s := string(body)
assert.NotContains(t, s, "bing-secret-do-not-leak")
// Parameters sub-object must contain no secret keys.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(body, &raw))
assert.NotContains(t, string(raw["parameters"]), `"api_key"`)
// Credential metadata map exposes booleans only.
assert.Contains(t, s, `"credentials"`)
assert.Contains(t, s, `"api_key":{"configured":true}`)
// Non-secret fields pass through.
assert.Contains(t, s, "engine-public-id")
assert.Contains(t, s, "example.com")
}
func TestWebSearchProviderResponse_NilSafe(t *testing.T) {
assert.Nil(t, NewWebSearchProviderResponse(nil))
assert.Equal(t, []*WebSearchProviderResponse{}, NewWebSearchProviderResponses(nil))
}

View File

@@ -0,0 +1,155 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// MCPCredentialsHandler handles secret credentials for MCP services via a
// dedicated subresource (/mcp-services/{id}/credentials). Splitting this out
// of UpdateMCPService delivers three concrete benefits:
//
// 1. The main MCP PUT body never carries secrets — eliminating the
// "masked-value round-trip overwrites stored secret" class of bug at the
// contract level rather than via runtime preserve-on-redacted defenses.
//
// 2. Saving the MCP edit dialog (changing timeout / enabled / etc.) cannot
// accidentally invalidate or clobber a working credential. Credential
// operations are explicit and atomic.
//
// 3. The "is this configured?" metadata travels on the main resource
// response (MCPServiceResponse.Credentials) — no separate GET endpoint
// needed. Only PUT and DELETE live here.
type MCPCredentialsHandler struct {
svc interfaces.MCPServiceService
}
// NewMCPCredentialsHandler constructs the handler.
func NewMCPCredentialsHandler(svc interfaces.MCPServiceService) *MCPCredentialsHandler {
return &MCPCredentialsHandler{svc: svc}
}
// mcpCredentialsPutRequest is the body shape for PUT /credentials. Both
// fields are pointers so the handler can distinguish "absent" (preserve)
// from "present and empty" (treat as no-op; clients should call DELETE to
// remove a credential). Non-empty values replace the stored secret.
type mcpCredentialsPutRequest struct {
APIKey *string `json:"api_key,omitempty"`
Token *string `json:"token,omitempty"`
}
// Put writes (creates or replaces) one or more credential fields on the MCP
// service. Triggers a connection recycle so the next upstream call uses the
// new credential.
//
// Put godoc
// @Summary 设置 MCP 服务凭据
// @Description 为指定字段写入新凭据;省略的字段保留原值;空字符串视为 no-op如需删除请用 DELETE
// @Tags MCP服务
// @Accept json
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param request body map[string]interface{} true "{api_key?: string, token?: string}"
// @Success 200 {object} map[string]interface{} "写入后的凭据状态"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "服务不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/credentials [put]
func (h *MCPCredentialsHandler) Put(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
var req mcpCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
// Nothing to do — but rather than 400 (benign no-op), look up current
// state and return it. Client treats this identically to a real save.
if req.APIKey == nil && req.Token == nil {
svc, err := h.svc.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil || svc == nil {
c.Error(errors.NewNotFoundError("MCP service not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: svc.AuthConfig != nil && svc.AuthConfig.APIKey != ""},
"token": {Configured: svc.AuthConfig != nil && svc.AuthConfig.Token != ""},
},
}})
return
}
updated, err := h.svc.UpdateMCPCredentials(ctx, tenantID, serviceID, req.APIKey, req.Token)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"service_id": secutils.SanitizeForLog(serviceID),
})
c.Error(errors.NewInternalServerError("failed to update credentials: " + err.Error()))
return
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: updated.AuthConfig != nil && updated.AuthConfig.APIKey != ""},
"token": {Configured: updated.AuthConfig != nil && updated.AuthConfig.Token != ""},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
// DeleteField removes one credential field. Recognized fields: "api_key",
// "token". Returns 204 on success (even if the field was already empty).
//
// DeleteField godoc
// @Summary 移除 MCP 服务的单个凭据字段
// @Description 删除指定字段的存储凭据;删除已为空的字段是幂等的
// @Tags MCP服务
// @Produce json
// @Param id path string true "MCP 服务 ID"
// @Param field path string true "字段名api_key | token"
// @Success 204
// @Failure 400 {object} errors.AppError "字段名非法"
// @Failure 404 {object} errors.AppError "服务不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /mcp-services/{id}/credentials/{field} [delete]
func (h *MCPCredentialsHandler) DeleteField(c *gin.Context) {
ctx := c.Request.Context()
serviceID := c.Param("id")
field := c.Param("field")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
if field != "api_key" && field != "token" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.svc.ClearMCPCredential(ctx, tenantID, serviceID, field); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"service_id": secutils.SanitizeForLog(serviceID),
"field": field,
})
c.Error(errors.NewInternalServerError("failed to clear credential: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/Tencent/WeKnora/internal/agent/approval"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
@@ -81,12 +82,11 @@ func (h *MCPServiceHandler) CreateMCPService(c *gin.Context) {
return
}
// Redact before echoing so the Create response honors the same
// "secrets never leave the server" invariant as List/Get/Update.
service.RedactSensitiveData()
// Response uses dto.MCPServiceResponse which omits secret fields by
// construction — no runtime redaction needed.
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": service,
"data": dto.NewMCPServiceResponse(&service),
})
}
@@ -120,7 +120,7 @@ func (h *MCPServiceHandler) ListMCPServices(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": services,
"data": dto.NewMCPServiceResponses(services),
})
}
@@ -154,15 +154,12 @@ func (h *MCPServiceHandler) GetMCPService(c *gin.Context) {
return
}
// Hide sensitive information for builtin MCP services
responseService := service
if service.IsBuiltin {
responseService = service.HideSensitiveInfo()
}
// dto.NewMCPServiceResponse omits secret fields and additionally strips
// transport details (URL/Headers/EnvVars/StdioConfig) for builtin services
// so the cross-tenant builtin list does not leak per-tenant config.
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responseService,
"data": dto.NewMCPServiceResponse(service),
})
}
@@ -275,26 +272,24 @@ func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) {
}
if authConfig, ok := updateData["auth_config"].(map[string]interface{}); ok {
service.AuthConfig = &types.MCPAuthConfig{}
if apiKey, ok := authConfig["api_key"].(string); ok {
service.AuthConfig.APIKey = apiKey
// Secret fields (api_key, token) are intentionally NOT read from the
// main PUT body — they live behind the /credentials subresource so
// editing unrelated config (timeout, enabled, etc.) cannot
// accidentally clobber a stored credential. Log a warning when a
// client still tries to send them so we can spot stale callers.
if _, present := authConfig["api_key"]; present {
logger.Warnf(ctx,
"deprecated: api_key in PUT /mcp-services/%s body is ignored; use PUT /credentials instead",
secutils.SanitizeForLog(serviceID))
}
if token, ok := authConfig["token"].(string); ok {
service.AuthConfig.Token = token
if _, present := authConfig["token"]; present {
logger.Warnf(ctx,
"deprecated: token in PUT /mcp-services/%s body is ignored; use PUT /credentials instead",
secutils.SanitizeForLog(serviceID))
}
// Write-only clear flags — must be explicitly pulled off the map
// since this handler intentionally uses a manual map-to-struct
// projection to support partial updates (see "Use map to handle
// partial updates" comment above). A struct-based bind would have
// picked these up automatically.
if clearAPIKey, ok := authConfig["clear_api_key"].(bool); ok {
service.AuthConfig.ClearAPIKey = clearAPIKey
}
if clearToken, ok := authConfig["clear_token"].(bool); ok {
service.AuthConfig.ClearToken = clearToken
}
// CustomHeaders — nil preserves, non-nil replaces (see
// MCPAuthConfig.MergeUpdate). Only copy when the key is present so
// the preserve-on-nil branch is reachable.
// CustomHeaders is structural (not a secret) — keep accepting it here.
// nil preserves existing, non-nil replaces; the service layer treats a
// nil CustomHeaders as "no change".
if customHeaders, ok := authConfig["custom_headers"].(map[string]interface{}); ok {
headers := make(map[string]string, len(customHeaders))
for k, v := range customHeaders {
@@ -325,13 +320,17 @@ func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) {
}
logger.Infof(ctx, "MCP service updated successfully: %s", secutils.SanitizeForLog(serviceID))
// Redact before echoing so the Update response honors the same
// "secrets never leave the server" invariant as List/Get/Create, and so
// write-only Clear* flags from the request body are never echoed back.
service.RedactSensitiveData()
// Re-fetch to pick up server-side merges (CustomHeaders preserve, etc.)
// and respond with the full current state via the secret-free DTO.
stored, err := h.mcpServiceService.GetMCPServiceByID(ctx, tenantID, serviceID)
if err != nil {
c.Error(errors.NewInternalServerError("Failed to fetch updated MCP service: " + err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": service,
"data": dto.NewMCPServiceResponse(stored),
})
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
@@ -30,55 +31,9 @@ func NewModelHandler(service interfaces.ModelService) *ModelHandler {
return &ModelHandler{service: service}
}
// hideSensitiveInfo prepares a Model for inclusion in a response body.
//
// For builtin models, APIKey and BaseURL are stripped entirely and only the
// non-credential fields (embedding dimensions, parameter size) survive on a
// fresh copy — builtin models are shared across tenants and must never leak
// their underlying provider configuration.
//
// For tenant-owned models the sensitive fields (APIKey, AppSecret) are
// replaced by the shared RedactedSecretPlaceholder via RedactSensitiveData,
// on a copy so the in-memory representation stays usable by subsequent
// code paths. Empty values stay empty so the frontend can render "set vs
// not set" without guesswork.
func hideSensitiveInfo(model *types.Model) *types.Model {
if model.IsBuiltin {
return &types.Model{
ID: model.ID,
TenantID: model.TenantID,
Name: model.Name,
Type: model.Type,
Source: model.Source,
Description: model.Description,
Parameters: types.ModelParameters{
// Hide APIKey and BaseURL for builtin models.
BaseURL: "",
APIKey: "",
// Preserve non-credential fields so the frontend can still
// render correct capability metadata (vision support, Ollama
// parameter size, interface type, provider identifier, and
// any embedding dimensions). These were previously zeroed
// out, which caused builtin chat cards to look like their
// multimodal / provider metadata was unset.
EmbeddingParameters: model.Parameters.EmbeddingParameters,
ParameterSize: model.Parameters.ParameterSize,
InterfaceType: model.Parameters.InterfaceType,
Provider: model.Parameters.Provider,
ExtraConfig: model.Parameters.ExtraConfig,
SupportsVision: model.Parameters.SupportsVision,
},
IsBuiltin: model.IsBuiltin,
Status: model.Status,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
// Tenant-owned model: redact secrets on a copy, preserving everything else.
cp := *model
cp.RedactSensitiveData()
return &cp
}
// Per-response redaction/stripping for Model now lives in
// dto.NewModelResponse — handlers must use it for every body that contains a
// model. The previous hideSensitiveInfo helper has been removed.
// CreateModelRequest defines the structure for model creation requests
// Contains all fields required to create a new model in the system
@@ -154,12 +109,9 @@ func (h *ModelHandler) CreateModel(c *gin.Context) {
secutils.SanitizeForLog(model.Name),
)
// Hide sensitive information for builtin models (though newly created models are unlikely to be builtin)
responseModel := hideSensitiveInfo(model)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": responseModel,
"data": dto.NewModelResponse(model),
})
}
@@ -202,15 +154,9 @@ func (h *ModelHandler) GetModel(c *gin.Context) {
logger.Infof(ctx, "Retrieved model successfully, ID: %s, Name: %s", model.ID, model.Name)
// Hide sensitive information for builtin models
responseModel := hideSensitiveInfo(model)
if model.IsBuiltin {
logger.Infof(ctx, "Builtin model detected, hiding sensitive information for model: %s", model.ID)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responseModel,
"data": dto.NewModelResponse(model),
})
}
@@ -246,18 +192,9 @@ func (h *ModelHandler) ListModels(c *gin.Context) {
logger.Infof(ctx, "Retrieved model list successfully, Tenant ID: %d, Total: %d models", tenantID, len(models))
// Hide sensitive information for builtin models in the list
responseModels := make([]*types.Model, len(models))
for i, model := range models {
responseModels[i] = hideSensitiveInfo(model)
if model.IsBuiltin {
logger.Infof(ctx, "Builtin model detected in list, hiding sensitive information for model: %s", model.ID)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responseModels,
"data": dto.NewModelResponses(models),
})
}
@@ -330,22 +267,30 @@ func (h *ModelHandler) UpdateModel(c *gin.Context) {
return
}
}
// Apply write-only secret merge semantics before overwriting the stored
// parameters: empty or redacted APIKey/AppSecret preserves the stored
// value, the respective ClearXxx flag wipes it, any other value replaces.
merged := req.Parameters.MergeUpdate(model.Parameters)
// Preserve backend-managed fields not sent by frontend.
merged.ParameterSize = model.Parameters.ParameterSize
if merged.ExtraConfig == nil {
merged.ExtraConfig = model.Parameters.ExtraConfig
// Credentials (api_key, app_secret) NEVER flow through this endpoint —
// they live behind the /credentials subresource. Force-preserve them by
// snapshotting the stored values before copying request fields in, so
// that even a misbehaving caller that puts api_key in the body cannot
// clobber a stored credential. Log a warning to spot stale callers.
storedAPIKey := model.Parameters.APIKey
storedAppSecret := model.Parameters.AppSecret
if req.Parameters.APIKey != "" && req.Parameters.APIKey != storedAPIKey {
logger.Warnf(ctx,
"deprecated: api_key in PUT /models/%s body is ignored; use PUT /credentials instead", id)
}
if req.Parameters.ClearAPIKey {
logger.Infof(ctx, "Model API key cleared by user: id=%s", id)
if req.Parameters.AppSecret != "" && req.Parameters.AppSecret != storedAppSecret {
logger.Warnf(ctx,
"deprecated: app_secret in PUT /models/%s body is ignored; use PUT /credentials instead", id)
}
if req.Parameters.ClearAppSecret {
logger.Infof(ctx, "Model app_secret cleared by user: id=%s", id)
newParams := req.Parameters
newParams.APIKey = storedAPIKey
newParams.AppSecret = storedAppSecret
// Preserve backend-managed fields not sent by the frontend either.
newParams.ParameterSize = model.Parameters.ParameterSize
if newParams.ExtraConfig == nil {
newParams.ExtraConfig = model.Parameters.ExtraConfig
}
model.Parameters = merged
model.Parameters = newParams
model.Source = req.Source
model.Type = req.Type
@@ -359,12 +304,9 @@ func (h *ModelHandler) UpdateModel(c *gin.Context) {
logger.Infof(ctx, "Model updated successfully, ID: %s", id)
// Hide sensitive information for builtin models (though builtin models cannot be updated)
responseModel := hideSensitiveInfo(model)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responseModel,
"data": dto.NewModelResponse(model),
})
}

View File

@@ -0,0 +1,109 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// ModelCredentialsHandler handles secret credentials for models via the
// dedicated /models/:id/credentials subresource. See mcp_credentials.go for
// the rationale; this handler mirrors that contract for Model resources.
//
// Recognized fields: "api_key" (every provider), "app_secret" (WeKnora Cloud).
type ModelCredentialsHandler struct {
svc interfaces.ModelService
}
func NewModelCredentialsHandler(svc interfaces.ModelService) *ModelCredentialsHandler {
return &ModelCredentialsHandler{svc: svc}
}
type modelCredentialsPutRequest struct {
APIKey *string `json:"api_key,omitempty"`
AppSecret *string `json:"app_secret,omitempty"`
}
func (h *ModelCredentialsHandler) Put(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
var req modelCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if req.APIKey == nil && req.AppSecret == nil {
m, err := h.svc.GetModelByID(ctx, id)
if err != nil || m == nil {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: m.Parameters.APIKey != ""},
"app_secret": {Configured: m.Parameters.AppSecret != ""},
},
}})
return
}
updated, err := h.svc.UpdateModelCredentials(ctx, id, req.APIKey, req.AppSecret)
if err != nil {
if err == service.ErrModelNotFound {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{"model_id": secutils.SanitizeForLog(id)})
c.Error(errors.NewInternalServerError("failed to update credentials: " + err.Error()))
return
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: updated.Parameters.APIKey != ""},
"app_secret": {Configured: updated.Parameters.AppSecret != ""},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
func (h *ModelCredentialsHandler) DeleteField(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
field := c.Param("field")
tenantID := c.GetUint64(types.TenantIDContextKey.String())
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
if field != "api_key" && field != "app_secret" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.svc.ClearModelCredential(ctx, id, field); err != nil {
if err == service.ErrModelNotFound {
c.Error(errors.NewNotFoundError("Model not found"))
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"model_id": secutils.SanitizeForLog(id),
"field": field,
})
c.Error(errors.NewInternalServerError("failed to clear credential: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
infra_web_search "github.com/Tencent/WeKnora/internal/infrastructure/web_search"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
@@ -108,10 +109,9 @@ func (h *WebSearchProviderHandler) CreateProvider(c *gin.Context) {
return
}
provider.RedactSensitiveData()
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": provider,
"data": dto.NewWebSearchProviderResponse(provider),
})
}
@@ -132,12 +132,9 @@ func (h *WebSearchProviderHandler) ListProviders(c *gin.Context) {
return
}
for _, p := range providers {
p.RedactSensitiveData()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": providers,
"data": dto.NewWebSearchProviderResponses(providers),
})
}
@@ -170,10 +167,9 @@ func (h *WebSearchProviderHandler) GetProvider(c *gin.Context) {
return
}
provider.RedactSensitiveData()
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": provider,
"data": dto.NewWebSearchProviderResponse(provider),
})
}
@@ -217,15 +213,22 @@ func (h *WebSearchProviderHandler) UpdateProvider(c *gin.Context) {
return
}
// Merge incoming parameters into the existing ones using write-only
// secret semantics: empty / redacted APIKey preserves the stored value,
// ClearAPIKey explicitly wipes it, any other value replaces. Non-secret
// fields flow through from the request directly.
mergedParams := req.Parameters.MergeUpdate(existing.Parameters)
if req.Parameters.ClearAPIKey {
logger.Infof(ctx, "WebSearchProvider API key cleared by user: id=%s",
// Credentials (api_key) NEVER flow through this endpoint — they live
// behind the /credentials subresource. Force-preserve the stored key
// regardless of what the body says; log a warning if a stale caller
// passes one so we can spot them.
if req.Parameters.APIKey != "" && req.Parameters.APIKey != existing.Parameters.APIKey {
logger.Warnf(ctx,
"deprecated: api_key in PUT /web-search-providers/%s body is ignored; use PUT /credentials instead",
secutils.SanitizeForLog(id))
}
mergedParams := req.Parameters
mergedParams.APIKey = existing.Parameters.APIKey
// Preserve ExtraConfig when the request omits it (nil); otherwise a
// partial PUT would silently drop tenant-configured extras.
if mergedParams.ExtraConfig == nil {
mergedParams.ExtraConfig = existing.Parameters.ExtraConfig
}
// Preserve existing values for top-level metadata fields when the
// request omits them (empty string from the JSON decoder). Without this,
@@ -260,8 +263,7 @@ func (h *WebSearchProviderHandler) UpdateProvider(c *gin.Context) {
// Re-fetch to get the full stored state
updated, _ := h.repo.GetByID(ctx, tenantID, id)
if updated != nil {
updated.RedactSensitiveData()
c.JSON(http.StatusOK, gin.H{"success": true, "data": updated})
c.JSON(http.StatusOK, gin.H{"success": true, "data": dto.NewWebSearchProviderResponse(updated)})
} else {
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -408,9 +410,6 @@ func (h *WebSearchProviderHandler) TestProviderRaw(c *gin.Context) {
// user knows they should type a real key or test against the saved config
// via /test instead.
func (h *WebSearchProviderHandler) doTestSearch(ctx context.Context, providerType string, params types.WebSearchProviderParameters) error {
if params.APIKey == types.RedactedSecretPlaceholder {
return fmt.Errorf("api_key looks like the redacted placeholder; enter your real key or call POST /api/v1/web-search-providers/{id}/test to exercise the saved credential")
}
logger.Infof(ctx, "[WebSearch][Test] testing provider type=%s", providerType)
searchProvider, err := h.registry.CreateProvider(providerType, params)
if err != nil {

View File

@@ -0,0 +1,104 @@
package handler
import (
"net/http"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/handler/dto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// WebSearchProviderCredentialsHandler handles credentials for web search
// providers via the dedicated /credentials subresource. Currently the only
// recognized field is "api_key" — every provider that needs credentials uses
// just one key (Bing / Google / Tavily / Ollama / Baidu), and DuckDuckGo /
// SearXNG don't need credentials at all.
type WebSearchProviderCredentialsHandler struct {
repo interfaces.WebSearchProviderRepository
svc interfaces.WebSearchProviderService
}
func NewWebSearchProviderCredentialsHandler(
repo interfaces.WebSearchProviderRepository,
svc interfaces.WebSearchProviderService,
) *WebSearchProviderCredentialsHandler {
return &WebSearchProviderCredentialsHandler{repo: repo, svc: svc}
}
func (h *WebSearchProviderCredentialsHandler) tenantID(c *gin.Context) uint64 {
return c.GetUint64(types.TenantIDContextKey.String())
}
type webSearchCredentialsPutRequest struct {
APIKey *string `json:"api_key,omitempty"`
}
func (h *WebSearchProviderCredentialsHandler) Put(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.tenantID(c)
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
id := c.Param("id")
var req webSearchCredentialsPutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.Error(errors.NewBadRequestError(err.Error()))
return
}
if req.APIKey == nil {
provider, err := h.repo.GetByID(ctx, tenantID, id)
if err != nil || provider == nil {
c.Error(errors.NewNotFoundError("web search provider not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: provider.Parameters.APIKey != ""},
},
}})
return
}
updated, err := h.svc.UpdateProviderCredentials(ctx, tenantID, id, req.APIKey)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"provider_id": secutils.SanitizeForLog(id),
})
c.Error(errors.NewInternalServerError("failed to update credentials: " + err.Error()))
return
}
resp := dto.CredentialsResponse{
Fields: map[string]dto.CredentialFieldMetadata{
"api_key": {Configured: updated.Parameters.APIKey != ""},
},
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
func (h *WebSearchProviderCredentialsHandler) DeleteField(c *gin.Context) {
ctx := c.Request.Context()
tenantID := h.tenantID(c)
if tenantID == 0 {
c.Error(errors.NewBadRequestError("Tenant ID cannot be empty"))
return
}
id := c.Param("id")
field := c.Param("field")
if field != "api_key" {
c.Error(errors.NewBadRequestError("unknown credential field: " + secutils.SanitizeForLog(field)))
return
}
if err := h.svc.ClearProviderCredential(ctx, tenantID, id, field); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"provider_id": secutils.SanitizeForLog(id),
"field": field,
})
c.Error(errors.NewInternalServerError("failed to clear credential: " + err.Error()))
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -52,13 +52,16 @@ type RouterParams struct {
SessionHandler *session.Handler
MessageHandler *handler.MessageHandler
ModelHandler *handler.ModelHandler
ModelCredentialsHandler *handler.ModelCredentialsHandler
EvaluationHandler *handler.EvaluationHandler
AuthHandler *handler.AuthHandler
InitializationHandler *handler.InitializationHandler
SystemHandler *handler.SystemHandler
MCPServiceHandler *handler.MCPServiceHandler
MCPCredentialsHandler *handler.MCPCredentialsHandler
WebSearchHandler *handler.WebSearchHandler
WebSearchProviderHandler *handler.WebSearchProviderHandler
WebSearchCredentialsHandler *handler.WebSearchProviderCredentialsHandler
VectorStoreHandler *handler.VectorStoreHandler
FAQHandler *handler.FAQHandler
TagHandler *handler.TagHandler
@@ -67,6 +70,7 @@ type RouterParams struct {
OrganizationHandler *handler.OrganizationHandler
IMHandler *handler.IMHandler
DataSourceHandler *handler.DataSourceHandler
DataSourceCredentialsHandler *handler.DataSourceCredentialsHandler
WeKnoraCloudHandler *handler.WeKnoraCloudHandler
WikiPageHandler *handler.WikiPageHandler
}
@@ -146,19 +150,19 @@ func NewRouter(params RouterParams) *gin.Engine {
RegisterSessionRoutes(v1, params.SessionHandler)
RegisterChatRoutes(v1, params.SessionHandler)
RegisterMessageRoutes(v1, params.MessageHandler)
RegisterModelRoutes(v1, params.ModelHandler)
RegisterModelRoutes(v1, params.ModelHandler, params.ModelCredentialsHandler)
RegisterEvaluationRoutes(v1, params.EvaluationHandler)
RegisterInitializationRoutes(v1, params.InitializationHandler)
RegisterSystemRoutes(v1, params.SystemHandler)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler)
RegisterMCPServiceRoutes(v1, params.MCPServiceHandler, params.MCPCredentialsHandler)
RegisterWebSearchRoutes(v1, params.WebSearchHandler)
RegisterWebSearchProviderRoutes(v1, params.WebSearchProviderHandler)
RegisterWebSearchProviderRoutes(v1, params.WebSearchProviderHandler, params.WebSearchCredentialsHandler)
RegisterVectorStoreRoutes(v1, params.VectorStoreHandler)
RegisterCustomAgentRoutes(v1, params.CustomAgentHandler)
RegisterSkillRoutes(v1, params.SkillHandler)
RegisterOrganizationRoutes(v1, params.OrganizationHandler)
RegisterIMChannelRoutes(v1, params.IMHandler)
RegisterDataSourceRoutes(v1, params.DataSourceHandler)
RegisterDataSourceRoutes(v1, params.DataSourceHandler, params.DataSourceCredentialsHandler)
RegisterWeKnoraCloudRoutes(v1, params.WeKnoraCloudHandler)
RegisterWikiPageRoutes(v1, params.WikiPageHandler)
RegisterChunkerDebugRoutes(v1)
@@ -399,7 +403,11 @@ func RegisterTenantRoutes(r *gin.RouterGroup, handler *handler.TenantHandler) {
}
// RegisterModelRoutes 注册模型相关的路由
func RegisterModelRoutes(r *gin.RouterGroup, handler *handler.ModelHandler) {
func RegisterModelRoutes(
r *gin.RouterGroup,
handler *handler.ModelHandler,
credHandler *handler.ModelCredentialsHandler,
) {
// 模型路由组
models := r.Group("/models")
{
@@ -415,6 +423,9 @@ func RegisterModelRoutes(r *gin.RouterGroup, handler *handler.ModelHandler) {
models.PUT("/:id", handler.UpdateModel)
// 删除模型
models.DELETE("/:id", handler.DeleteModel)
// Per-field credential subresource (see internal/handler/model_credentials.go).
models.PUT("/:id/credentials", credHandler.Put)
models.DELETE("/:id/credentials/:field", credHandler.DeleteField)
}
}
@@ -481,7 +492,11 @@ func RegisterSystemRoutes(r *gin.RouterGroup, handler *handler.SystemHandler) {
}
// RegisterMCPServiceRoutes registers MCP service routes
func RegisterMCPServiceRoutes(r *gin.RouterGroup, handler *handler.MCPServiceHandler) {
func RegisterMCPServiceRoutes(
r *gin.RouterGroup,
handler *handler.MCPServiceHandler,
credHandler *handler.MCPCredentialsHandler,
) {
mcpServices := r.Group("/mcp-services")
{
// Create MCP service
@@ -500,6 +515,10 @@ func RegisterMCPServiceRoutes(r *gin.RouterGroup, handler *handler.MCPServiceHan
mcpServices.GET("/:id/tools", handler.GetMCPServiceTools)
// Get MCP service resources
mcpServices.GET("/:id/resources", handler.GetMCPServiceResources)
// Per-field credential subresource: secrets never travel via the main
// PUT body. See internal/handler/mcp_credentials.go for the contract.
mcpServices.PUT("/:id/credentials", credHandler.Put)
mcpServices.DELETE("/:id/credentials/:field", credHandler.DeleteField)
// MCP tool human approval (issue #1173)
mcpServices.GET("/:id/tool-approvals", handler.ListMCPToolApprovals)
mcpServices.PUT("/:id/tool-approvals/:tool_name", handler.SetMCPToolApproval)
@@ -522,7 +541,11 @@ func RegisterWebSearchRoutes(r *gin.RouterGroup, webSearchHandler *handler.WebSe
}
// RegisterWebSearchProviderRoutes registers CRUD routes for web search provider configurations
func RegisterWebSearchProviderRoutes(r *gin.RouterGroup, h *handler.WebSearchProviderHandler) {
func RegisterWebSearchProviderRoutes(
r *gin.RouterGroup,
h *handler.WebSearchProviderHandler,
credHandler *handler.WebSearchProviderCredentialsHandler,
) {
providers := r.Group("/web-search-providers")
{
// List available provider types (metadata for UI forms)
@@ -535,6 +558,9 @@ func RegisterWebSearchProviderRoutes(r *gin.RouterGroup, h *handler.WebSearchPro
providers.GET("/:id", h.GetProvider)
providers.PUT("/:id", h.UpdateProvider)
providers.DELETE("/:id", h.DeleteProvider)
// Per-field credential subresource.
providers.PUT("/:id/credentials", credHandler.Put)
providers.DELETE("/:id/credentials/:field", credHandler.DeleteField)
// Test existing saved provider
providers.POST("/:id/test", h.TestProviderByID)
}
@@ -964,7 +990,11 @@ func servePresignedFiles(r *gin.Engine, tenantService interfaces.TenantService)
}
// RegisterDataSourceRoutes 注册数据源相关的路由
func RegisterDataSourceRoutes(r *gin.RouterGroup, handler *handler.DataSourceHandler) {
func RegisterDataSourceRoutes(
r *gin.RouterGroup,
handler *handler.DataSourceHandler,
credHandler *handler.DataSourceCredentialsHandler,
) {
// Data source routes
ds := r.Group("/datasource")
{
@@ -981,6 +1011,12 @@ func RegisterDataSourceRoutes(r *gin.RouterGroup, handler *handler.DataSourceHan
ds.PUT("/:id", handler.UpdateDataSource)
ds.DELETE("/:id", handler.DeleteDataSource)
// Credential subresource. Single logical field "credentials" because
// connector credentials are a per-connector atomic map (see
// internal/handler/datasource_credentials.go).
ds.PUT("/:id/credentials", credHandler.Put)
ds.DELETE("/:id/credentials/:field", credHandler.DeleteField)
// Connection and resource management
ds.POST("/:id/validate", handler.ValidateConnection)
ds.GET("/:id/resources", handler.ListAvailableResources)

View File

@@ -196,13 +196,10 @@ func (s *SyncLog) BeforeCreate(tx *gorm.DB) error {
// DataSourceConfig represents the unencrypted configuration structure
// Each connector type will have its own specific fields.
//
// ClearCredentials is a write-only flag used in Update requests to
// explicitly remove the stored credential map. Because credentials are
// per-connector atomic sets (e.g. an OAuth token pair, a GitHub PAT, a
// Confluence API token), individual-field removal is intentionally not
// supported — revoking one field in isolation would leave the connector in
// a broken half-configured state. The flag is never persisted to storage
// and never surfaces in responses (omitempty + zero value).
// Credential management lives in the dedicated /credentials subresource
// (see internal/handler/datasource_credentials.go). Secret values are never
// included in API responses — handlers serialize via dto.NewDataSourceResponse
// which strips the Credentials map by construction.
type DataSourceConfig struct {
// Common fields applicable to most connectors
Type string `json:"type"`
@@ -215,100 +212,15 @@ type DataSourceConfig struct {
// Connector-specific configuration
Settings map[string]interface{} `json:"settings"`
// Write-only flag — request-only, never persisted or returned.
ClearCredentials bool `json:"clear_credentials,omitempty"`
}
// HasCredentials reports whether the credentials map carries any value at
// all. Used by the Update path to detect the "Remove all credentials"
// transition (existing.HasCredentials() && !merged.HasCredentials()) and
// skip live-connector validation that would otherwise reject the wipe.
// all. Used by the Update path and by the credential subresource to decide
// whether to run live-connector validation.
func (d DataSourceConfig) HasCredentials() bool {
return len(d.Credentials) > 0
}
// Redacted returns a copy of the config with every string value in
// Credentials replaced by RedactedSecretPlaceholder so it can be returned to
// the client without exposing stored secrets. Empty string values stay empty
// so the frontend can still distinguish "set (hidden)" from "not set" per
// key. Non-string credential values (rare but possible for numeric IDs or
// structured settings) pass through unchanged.
//
// Named "Redacted" rather than "RedactSensitiveData" because it returns a
// copy instead of mutating in place, matching the naming convention of
// ConnectionConfig.MaskSensitiveFields in types/vectorstore.go.
func (d DataSourceConfig) Redacted() DataSourceConfig {
cp := d
// ClearCredentials is a request-only signal; never echo it in responses.
cp.ClearCredentials = false
if d.Credentials == nil {
return cp
}
redacted := make(map[string]interface{}, len(d.Credentials))
for k, v := range d.Credentials {
if s, ok := v.(string); ok && s != "" {
redacted[k] = RedactedSecretPlaceholder
} else {
redacted[k] = v
}
}
cp.Credentials = redacted
return cp
}
// MergeUpdate applies write-only secret merge semantics for Update requests.
//
// - ClearCredentials=true → wipe the entire credentials map
// - Otherwise, each string credential key is merged individually using
// PreserveIfRedacted: empty or the redacted placeholder preserves the
// existing value, any other value replaces. Non-string credential
// values from incoming always overwrite (they aren't redacted either).
// - Non-secret fields (Type, ResourceIDs, Settings) flow from incoming
// directly.
// - The write-only ClearCredentials flag is cleared on the merged result.
func (d DataSourceConfig) MergeUpdate(existing DataSourceConfig) DataSourceConfig {
merged := d
merged.ClearCredentials = false
if d.ClearCredentials {
merged.Credentials = nil
return merged
}
mergedCreds := make(map[string]interface{}, len(existing.Credentials)+len(d.Credentials))
for k, v := range existing.Credentials {
mergedCreds[k] = v
}
for k, v := range d.Credentials {
if s, ok := v.(string); ok {
if IsRedactedOrEmpty(s) {
continue // preserve existing value for this key
}
}
mergedCreds[k] = v
}
merged.Credentials = mergedCreds
return merged
}
// RedactSensitiveData parses the Config jsonb, redacts string values in the
// Credentials map, and writes the result back. No-op when Config is empty or
// fails to parse (the caller continues to return the DataSource with
// whatever Config representation it has — better than dropping the response).
func (d *DataSource) RedactSensitiveData() {
if len(d.Config) == 0 {
return
}
parsed, err := d.ParseConfig()
if err != nil || parsed == nil {
return
}
redacted := parsed.Redacted()
if blob, err := redacted.ToJSON(); err == nil {
d.Config = blob
}
}
// Resource represents a syncable resource (document, folder, space) from external system
type Resource struct {
// Unique identifier in the external system

View File

@@ -1,206 +0,0 @@
package types
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDataSourceConfig_Redacted(t *testing.T) {
t.Run("redacts all string values in Credentials", func(t *testing.T) {
cfg := DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"access_token": "real-pat",
"installation_id": "real-id",
},
ResourceIDs: []string{"repo-1"},
}
out := cfg.Redacted()
assert.Equal(t, RedactedSecretPlaceholder, out.Credentials["access_token"])
assert.Equal(t, RedactedSecretPlaceholder, out.Credentials["installation_id"])
assert.Equal(t, "github", out.Type, "non-credential fields preserved")
assert.Equal(t, []string{"repo-1"}, out.ResourceIDs)
})
t.Run("leaves empty string credentials empty", func(t *testing.T) {
cfg := DataSourceConfig{
Credentials: map[string]interface{}{"token": ""},
}
out := cfg.Redacted()
assert.Equal(t, "", out.Credentials["token"],
"empty must stay empty to signal 'not set' to the frontend")
})
t.Run("non-string credential values pass through unchanged", func(t *testing.T) {
cfg := DataSourceConfig{
Credentials: map[string]interface{}{
"token": "secret",
"installation_id": float64(12345),
"metadata": map[string]interface{}{"nested": "value"},
},
}
out := cfg.Redacted()
assert.Equal(t, RedactedSecretPlaceholder, out.Credentials["token"])
assert.Equal(t, float64(12345), out.Credentials["installation_id"])
assert.NotNil(t, out.Credentials["metadata"])
})
t.Run("does not mutate original", func(t *testing.T) {
cfg := DataSourceConfig{
Credentials: map[string]interface{}{"token": "secret"},
}
_ = cfg.Redacted()
assert.Equal(t, "secret", cfg.Credentials["token"])
})
t.Run("nil credentials map stays nil", func(t *testing.T) {
cfg := DataSourceConfig{Type: "github"}
out := cfg.Redacted()
assert.Nil(t, out.Credentials)
})
t.Run("ClearCredentials flag stripped from response", func(t *testing.T) {
cfg := DataSourceConfig{ClearCredentials: true, Type: "github"}
out := cfg.Redacted()
assert.False(t, out.ClearCredentials, "write-only flag must not echo back")
})
}
func TestDataSourceConfig_MergeUpdate(t *testing.T) {
existing := DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"access_token": "stored-pat",
"installation_id": "stored-id",
},
ResourceIDs: []string{"old-repo"},
}
t.Run("empty credential value preserves existing", func(t *testing.T) {
in := DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"access_token": "",
},
}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-pat", out.Credentials["access_token"])
assert.Equal(t, "stored-id", out.Credentials["installation_id"],
"keys not present in incoming carry over from existing")
})
t.Run("redacted placeholder preserves existing", func(t *testing.T) {
in := DataSourceConfig{
Credentials: map[string]interface{}{
"access_token": RedactedSecretPlaceholder,
},
}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-pat", out.Credentials["access_token"])
})
t.Run("real value replaces existing", func(t *testing.T) {
in := DataSourceConfig{
Credentials: map[string]interface{}{
"access_token": "new-pat",
},
}
out := in.MergeUpdate(existing)
assert.Equal(t, "new-pat", out.Credentials["access_token"])
assert.Equal(t, "stored-id", out.Credentials["installation_id"],
"other keys preserved from existing")
})
t.Run("mixed preserve and replace", func(t *testing.T) {
in := DataSourceConfig{
Credentials: map[string]interface{}{
"access_token": RedactedSecretPlaceholder, // preserve
"installation_id": "new-id", // replace
},
}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-pat", out.Credentials["access_token"])
assert.Equal(t, "new-id", out.Credentials["installation_id"])
})
t.Run("ClearCredentials wipes entire map", func(t *testing.T) {
in := DataSourceConfig{ClearCredentials: true}
out := in.MergeUpdate(existing)
assert.Nil(t, out.Credentials,
"ClearCredentials removes all credential keys atomically")
})
t.Run("ClearCredentials takes precedence over submitted values", func(t *testing.T) {
in := DataSourceConfig{
ClearCredentials: true,
Credentials: map[string]interface{}{
"access_token": "will-be-ignored",
},
}
out := in.MergeUpdate(existing)
assert.Nil(t, out.Credentials)
})
t.Run("ClearCredentials flag never persists on merged result", func(t *testing.T) {
in := DataSourceConfig{ClearCredentials: true}
out := in.MergeUpdate(existing)
assert.False(t, out.ClearCredentials, "write-only flag must not leak to storage")
})
t.Run("non-string incoming values always overwrite", func(t *testing.T) {
in := DataSourceConfig{
Credentials: map[string]interface{}{
"installation_id": float64(999),
},
}
out := in.MergeUpdate(existing)
assert.Equal(t, float64(999), out.Credentials["installation_id"],
"non-string values are never treated as redacted/empty")
})
t.Run("non-credential fields flow from incoming", func(t *testing.T) {
in := DataSourceConfig{
Type: "notion",
ResourceIDs: []string{"new-repo"},
}
out := in.MergeUpdate(existing)
assert.Equal(t, "notion", out.Type)
assert.Equal(t, []string{"new-repo"}, out.ResourceIDs)
})
}
func TestDataSource_RedactSensitiveData(t *testing.T) {
t.Run("redacts string credentials in Config jsonb", func(t *testing.T) {
cfg := DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"access_token": "real-pat",
},
}
blob, err := json.Marshal(cfg)
require.NoError(t, err)
ds := &DataSource{Config: JSON(blob)}
ds.RedactSensitiveData()
parsed, err := ds.ParseConfig()
require.NoError(t, err)
assert.Equal(t, RedactedSecretPlaceholder, parsed.Credentials["access_token"])
})
t.Run("no-op on empty config", func(t *testing.T) {
ds := &DataSource{Config: nil}
ds.RedactSensitiveData() // must not panic
assert.Nil(t, ds.Config)
})
t.Run("no-op on malformed config", func(t *testing.T) {
ds := &DataSource{Config: JSON(`not valid json`)}
ds.RedactSensitiveData() // must not panic
// Config remains unchanged — better than dropping the response.
assert.Equal(t, JSON(`not valid json`), ds.Config)
})
}

View File

@@ -24,6 +24,18 @@ type DataSourceService interface {
// DeleteDataSource deletes a data source (soft delete)
DeleteDataSource(ctx context.Context, id string) error
// UpdateDataSourceCredentials replaces the connector credential map.
// DataSource credentials are per-connector atomic — there is no
// individual-field PUT, the whole map gets replaced. Returns the updated
// data source so the caller can re-fetch the redacted shape.
UpdateDataSourceCredentials(
ctx context.Context, id string, credentials map[string]interface{},
) (*types.DataSource, error)
// ClearDataSourceCredentials wipes the connector credential map.
// Idempotent on already-empty credentials.
ClearDataSourceCredentials(ctx context.Context, id string) error
// ValidateConnection tests the connection to an external data source
ValidateConnection(ctx context.Context, dsID string) error

View File

@@ -58,4 +58,21 @@ type MCPServiceService interface {
// GetMCPServiceResources retrieves the list of resources from an MCP service
GetMCPServiceResources(ctx context.Context, tenantID uint64, id string) ([]*types.MCPResource, error)
// UpdateMCPCredentials writes one or more credential fields on the auth
// config. Nil pointer means "do not touch this field". Returns the updated
// service (with current AuthConfig) so the handler can derive the
// configured/not-configured metadata for the response.
//
// Implementations MUST close any active MCP client connection for this
// service so the next upstream call reconnects with the new credential.
UpdateMCPCredentials(
ctx context.Context, tenantID uint64, id string, apiKey *string, token *string,
) (*types.MCPService, error)
// ClearMCPCredential removes a single credential field. field must be
// "api_key" or "token"; other values must be rejected by the caller.
// Implementations MUST close any active MCP client connection for this
// service. Clearing a field that is already empty is a no-op (no error).
ClearMCPCredential(ctx context.Context, tenantID uint64, id, field string) error
}

View File

@@ -23,6 +23,15 @@ type ModelService interface {
UpdateModel(ctx context.Context, model *types.Model) error
// DeleteModel deletes a model
DeleteModel(ctx context.Context, id string) error
// UpdateModelCredentials writes one or more credential fields on the
// model's Parameters. Nil pointer means "do not touch this field";
// empty string is treated as no-op (use ClearModelCredential to remove).
// Returns the updated model.
UpdateModelCredentials(ctx context.Context, id string, apiKey, appSecret *string) (*types.Model, error)
// ClearModelCredential removes a single credential field. field must be
// "api_key" or "app_secret". Clearing an already-empty field is a no-op.
ClearModelCredential(ctx context.Context, id, field string) error
// GetEmbeddingModel gets an embedding model
GetEmbeddingModel(ctx context.Context, modelId string) (embedding.Embedder, error)
// GetEmbeddingModelForTenant gets an embedding model for a specific tenant (for cross-tenant sharing)

View File

@@ -36,4 +36,14 @@ type WebSearchProviderService interface {
UpdateProvider(ctx context.Context, provider *types.WebSearchProviderEntity) error
// DeleteProvider deletes a provider by tenant + id.
DeleteProvider(ctx context.Context, tenantID uint64, id string) error
// UpdateProviderCredentials writes one or more credential fields.
// apiKey nil means "do not touch"; empty string is a no-op (clearing
// goes through ClearProviderCredential). Returns the updated entity.
UpdateProviderCredentials(
ctx context.Context, tenantID uint64, id string, apiKey *string,
) (*types.WebSearchProviderEntity, error)
// ClearProviderCredential removes a single credential field. Currently
// only "api_key" is recognized. Idempotent on already-empty fields.
ClearProviderCredential(ctx context.Context, tenantID uint64, id, field string) error
}

View File

@@ -43,58 +43,15 @@ type MCPHeaders map[string]string
// MCPAuthConfig represents authentication configuration for MCP service.
//
// ClearAPIKey and ClearToken are write-only flags used in Update requests to
// explicitly remove a stored credential. They are not persisted (gorm:"-") and
// never appear in responses (omitempty + zero value). Without these flags an
// empty APIKey/Token submitted in an Update request is interpreted as "no
// change", so this is the only way to intentionally clear a stored secret.
// Secret fields (APIKey, Token) are persisted in this struct but are NEVER
// returned through main resource responses — those go through
// dto.MCPServiceResponse which omits them by construction. Credential
// mutations happen through the dedicated /credentials subresource handled
// by MCPCredentialsHandler.
type MCPAuthConfig struct {
APIKey string `json:"api_key,omitempty"`
Token string `json:"token,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Write-only clear flags.
ClearAPIKey bool `json:"clear_api_key,omitempty" gorm:"-"`
ClearToken bool `json:"clear_token,omitempty" gorm:"-"`
}
// MergeUpdate applies write-only secret merge semantics for Update requests.
// The receiver is the incoming request DTO; existing is the currently stored
// value (may be nil for new services).
//
// Behavior:
// - ClearAPIKey / ClearToken take precedence — wipe the corresponding field.
// - Otherwise APIKey / Token use PreserveIfRedacted: empty or the redacted
// placeholder preserves existing, any other value replaces.
// - CustomHeaders: nil (absent in request) preserves existing; any non-nil
// value (including an empty map) replaces. Callers that want to clear
// headers explicitly should send {} rather than omitting the field.
// - Write-only clear flags are cleared on the merged result so they never
// leak to storage.
func (c *MCPAuthConfig) MergeUpdate(existing *MCPAuthConfig) *MCPAuthConfig {
merged := &MCPAuthConfig{}
if existing != nil {
*merged = *existing
merged.ClearAPIKey = false
merged.ClearToken = false
}
if c == nil {
return merged
}
if c.ClearAPIKey {
merged.APIKey = ""
} else {
merged.APIKey = PreserveIfRedacted(c.APIKey, merged.APIKey)
}
if c.ClearToken {
merged.Token = ""
} else {
merged.Token = PreserveIfRedacted(c.Token, merged.Token)
}
if c.CustomHeaders != nil {
merged.CustomHeaders = c.CustomHeaders
}
return merged
}
// MCPAdvancedConfig represents advanced configuration for MCP service
@@ -277,44 +234,10 @@ func GetDefaultAdvancedConfig() *MCPAdvancedConfig {
}
}
// RedactSensitiveData replaces secret values in the MCP service with
// RedactedSecretPlaceholder while preserving the "set vs not set" distinction
// (empty values stay empty). Mutates the receiver in place; callers that need
// to retain the originals should pass a copy.
//
// This replaces the previous MaskSensitiveData implementation which returned
// a "first4****last4" slice of the original secret, leaking 8 characters of
// entropy per value. The new implementation returns a fixed placeholder with
// no information about the underlying secret.
func (m *MCPService) RedactSensitiveData() {
if m.AuthConfig == nil {
return
}
if m.AuthConfig.APIKey != "" {
m.AuthConfig.APIKey = RedactedSecretPlaceholder
}
if m.AuthConfig.Token != "" {
m.AuthConfig.Token = RedactedSecretPlaceholder
}
// Write-only flags must never reach the client. omitempty alone is
// insufficient because a true value serializes; explicitly clear them so
// any response path that runs RedactSensitiveData is safe by construction.
m.AuthConfig.ClearAPIKey = false
m.AuthConfig.ClearToken = false
}
// HideSensitiveInfo returns a copy of the MCP service with sensitive fields cleared for builtin services
func (m *MCPService) HideSensitiveInfo() *MCPService {
if !m.IsBuiltin {
return m
}
copy := *m
copy.URL = nil
copy.AuthConfig = nil
copy.Headers = nil
copy.EnvVars = nil
copy.StdioConfig = nil
return &copy
}
// Redaction / sensitive-field stripping for MCPService now happens at the
// response DTO layer (internal/handler/dto.NewMCPServiceResponse), not via a
// method on the entity. This keeps the "no secret in responses" guarantee a
// compile-time invariant of the DTO instead of a runtime call that handlers
// must remember to make. Builtin-service field stripping is also implemented
// there.

View File

@@ -1,52 +0,0 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMCPService_RedactSensitiveData(t *testing.T) {
t.Run("redacts both api_key and token when set", func(t *testing.T) {
m := &MCPService{
AuthConfig: &MCPAuthConfig{
APIKey: "real-api",
Token: "real-token",
},
}
m.RedactSensitiveData()
assert.Equal(t, RedactedSecretPlaceholder, m.AuthConfig.APIKey)
assert.Equal(t, RedactedSecretPlaceholder, m.AuthConfig.Token)
})
t.Run("leaves empty secrets empty", func(t *testing.T) {
m := &MCPService{AuthConfig: &MCPAuthConfig{}}
m.RedactSensitiveData()
assert.Empty(t, m.AuthConfig.APIKey, "empty must stay empty to signal 'not set' to the frontend")
assert.Empty(t, m.AuthConfig.Token)
})
t.Run("noop on nil AuthConfig", func(t *testing.T) {
m := &MCPService{AuthConfig: nil}
assert.NotPanics(t, func() { m.RedactSensitiveData() })
assert.Nil(t, m.AuthConfig)
})
t.Run("zeroes write-only clear flags so they never echo back", func(t *testing.T) {
// Regression for response-side leak: a true Clear* bool serializes
// despite json:"...,omitempty" because true is not a zero value.
// Any response path that runs RedactSensitiveData must produce JSON
// without the clear_api_key / clear_token keys.
m := &MCPService{
AuthConfig: &MCPAuthConfig{
APIKey: "real-api",
Token: "real-token",
ClearAPIKey: true,
ClearToken: true,
},
}
m.RedactSensitiveData()
assert.False(t, m.AuthConfig.ClearAPIKey, "ClearAPIKey must be reset before response")
assert.False(t, m.AuthConfig.ClearToken, "ClearToken must be reset before response")
})
}

View File

@@ -77,58 +77,13 @@ type ModelParameters struct {
// WeKnoraCloud 厂商专用凭证
AppID string `yaml:"app_id,omitempty" json:"app_id,omitempty"`
AppSecret string `yaml:"app_secret,omitempty" json:"app_secret,omitempty"` // AES-256 加密存储,实际承载上游 API Key
// Write-only flags: set to true in Update requests to explicitly remove
// the corresponding credential. Never persisted (gorm:"-"), never
// returned in responses (omitempty + zero value).
ClearAPIKey bool `yaml:"-" json:"clear_api_key,omitempty" gorm:"-"`
ClearAppSecret bool `yaml:"-" json:"clear_app_secret,omitempty" gorm:"-"`
}
// RedactSensitiveData replaces secret values (APIKey, AppSecret) in
// ModelParameters with RedactedSecretPlaceholder when set. Empty values stay
// empty so the frontend can distinguish "set (hidden)" from "not set".
// Mutates the receiver in place.
func (m *Model) RedactSensitiveData() {
if m.Parameters.APIKey != "" {
m.Parameters.APIKey = RedactedSecretPlaceholder
}
if m.Parameters.AppSecret != "" {
m.Parameters.AppSecret = RedactedSecretPlaceholder
}
// Write-only flags must never reach the client. omitempty alone is
// insufficient because a true value serializes; explicitly clear them so
// any response path that runs RedactSensitiveData is safe by construction.
m.Parameters.ClearAPIKey = false
m.Parameters.ClearAppSecret = false
}
// MergeUpdate applies write-only secret merge semantics for Update requests:
//
// - ClearAPIKey / ClearAppSecret take precedence — when true, the
// corresponding field becomes empty
// - Otherwise APIKey and AppSecret use PreserveIfRedacted: empty or the
// redacted placeholder preserves the existing value, any other value
// replaces
// - Non-secret fields flow from incoming directly
// - Write-only clear flags are cleared on the merged result so they never
// leak to storage
func (p ModelParameters) MergeUpdate(existing ModelParameters) ModelParameters {
merged := p
if p.ClearAPIKey {
merged.APIKey = ""
} else {
merged.APIKey = PreserveIfRedacted(p.APIKey, existing.APIKey)
}
if p.ClearAppSecret {
merged.AppSecret = ""
} else {
merged.AppSecret = PreserveIfRedacted(p.AppSecret, existing.AppSecret)
}
merged.ClearAPIKey = false
merged.ClearAppSecret = false
return merged
}
// Per-response redaction for Model now lives in dto.NewModelResponse. The
// previous RedactSensitiveData method has been removed because handlers must
// always serialize through the DTO, where the secret fields don't even
// exist; a runtime mutator on the entity is both redundant and a footgun
// (mutates an entity that other code may still be using).
// Model represents the AI model
type Model struct {

View File

@@ -1,139 +0,0 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestModel_RedactSensitiveData(t *testing.T) {
t.Run("redacts both APIKey and AppSecret when set", func(t *testing.T) {
m := &Model{
Parameters: ModelParameters{
APIKey: "real-api",
AppSecret: "real-secret",
BaseURL: "https://example.com",
},
}
m.RedactSensitiveData()
assert.Equal(t, RedactedSecretPlaceholder, m.Parameters.APIKey)
assert.Equal(t, RedactedSecretPlaceholder, m.Parameters.AppSecret)
assert.Equal(t, "https://example.com", m.Parameters.BaseURL,
"non-secret fields must be preserved")
})
t.Run("leaves empty secrets empty", func(t *testing.T) {
m := &Model{
Parameters: ModelParameters{BaseURL: "https://example.com"},
}
m.RedactSensitiveData()
assert.Empty(t, m.Parameters.APIKey)
assert.Empty(t, m.Parameters.AppSecret)
})
t.Run("redacts only set secret when one is empty", func(t *testing.T) {
m := &Model{
Parameters: ModelParameters{APIKey: "real-api"}, // AppSecret empty
}
m.RedactSensitiveData()
assert.Equal(t, RedactedSecretPlaceholder, m.Parameters.APIKey)
assert.Empty(t, m.Parameters.AppSecret)
})
t.Run("zeroes write-only clear flags so they never echo back", func(t *testing.T) {
// Regression for response-side leak: a true Clear* bool serializes
// despite json:"...,omitempty" because true is not a zero value.
// Any response path that runs RedactSensitiveData must produce JSON
// without the clear_* keys.
m := &Model{
Parameters: ModelParameters{
APIKey: "real-api",
ClearAPIKey: true,
ClearAppSecret: true,
},
}
m.RedactSensitiveData()
assert.False(t, m.Parameters.ClearAPIKey, "ClearAPIKey must be reset before response")
assert.False(t, m.Parameters.ClearAppSecret, "ClearAppSecret must be reset before response")
})
}
func TestModelParameters_MergeUpdate(t *testing.T) {
existing := ModelParameters{
APIKey: "stored-api",
AppSecret: "stored-secret",
BaseURL: "https://stored.example.com",
Provider: "openai",
}
t.Run("empty secrets preserve existing", func(t *testing.T) {
in := ModelParameters{BaseURL: "https://new.example.com"}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-api", out.APIKey)
assert.Equal(t, "stored-secret", out.AppSecret)
assert.Equal(t, "https://new.example.com", out.BaseURL,
"non-secret fields flow from incoming")
})
t.Run("redacted placeholders preserve existing", func(t *testing.T) {
in := ModelParameters{
APIKey: RedactedSecretPlaceholder,
AppSecret: RedactedSecretPlaceholder,
}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-api", out.APIKey)
assert.Equal(t, "stored-secret", out.AppSecret)
})
t.Run("real values replace existing", func(t *testing.T) {
in := ModelParameters{APIKey: "new-api", AppSecret: "new-secret"}
out := in.MergeUpdate(existing)
assert.Equal(t, "new-api", out.APIKey)
assert.Equal(t, "new-secret", out.AppSecret)
})
t.Run("mixed preserve and replace", func(t *testing.T) {
in := ModelParameters{
APIKey: RedactedSecretPlaceholder, // preserve
AppSecret: "new-secret", // replace
}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-api", out.APIKey)
assert.Equal(t, "new-secret", out.AppSecret)
})
t.Run("ClearAPIKey wipes only APIKey", func(t *testing.T) {
in := ModelParameters{ClearAPIKey: true}
out := in.MergeUpdate(existing)
assert.Empty(t, out.APIKey)
assert.Equal(t, "stored-secret", out.AppSecret,
"ClearAPIKey must not affect AppSecret")
})
t.Run("ClearAppSecret wipes only AppSecret", func(t *testing.T) {
in := ModelParameters{ClearAppSecret: true}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-api", out.APIKey)
assert.Empty(t, out.AppSecret)
})
t.Run("both clear flags wipe both secrets", func(t *testing.T) {
in := ModelParameters{ClearAPIKey: true, ClearAppSecret: true}
out := in.MergeUpdate(existing)
assert.Empty(t, out.APIKey)
assert.Empty(t, out.AppSecret)
})
t.Run("clear takes precedence over submitted value", func(t *testing.T) {
in := ModelParameters{APIKey: "will-be-ignored", ClearAPIKey: true}
out := in.MergeUpdate(existing)
assert.Empty(t, out.APIKey)
})
t.Run("clear flags never persist on merged result", func(t *testing.T) {
in := ModelParameters{ClearAPIKey: true, ClearAppSecret: true}
out := in.MergeUpdate(existing)
assert.False(t, out.ClearAPIKey, "write-only flag must not leak to storage")
assert.False(t, out.ClearAppSecret, "write-only flag must not leak to storage")
})
}

View File

@@ -1,56 +1,23 @@
// Package types — shared secret redaction helpers for the "write-only secrets"
// pattern applied across MCP services, Models, WebSearch providers,
// DataSources, and VectorStores.
// Package types — shared secret-handling helpers.
//
// The contract is:
// Historically this file also held PreserveIfRedacted / IsRedactedOrEmpty
// to support the old "echo redacted placeholder back, server merges on
// Update" pattern shared by MCP / Model / WebSearch / DataSource. Those
// resources have since moved to the credential-resource pattern (a
// dedicated /credentials subresource), so the merge helpers were removed.
//
// - API responses replace sensitive values with RedactedSecretPlaceholder
// when the value is set, and leave it as the empty string when it is not.
// Clients can therefore distinguish "set (hidden)" from "not set" without
// ever seeing the secret itself.
//
// - API Update requests treat an absent field, the empty string, or the
// RedactedSecretPlaceholder as "no change requested" (preserve), and any
// other value as an explicit replacement.
//
// - Explicit removal of a stored secret is expressed with a dedicated
// write-only boolean flag on the request DTO (e.g. ClearAPIKey) rather
// than by sending an empty string, because the empty string is already
// reserved for "preserve".
// The placeholder constant survives because VectorStore connection configs
// still inline-redact a Password / APIKey on response (see
// types/vectorstore.go → ConnectionConfig.MaskSensitiveFields); migrating
// VectorStore to the credential-resource pattern is left for a future PR
// because it would require introducing a separate connection record (the
// secret currently lives inline on the knowledge base config).
package types
// RedactedSecretPlaceholder is the fixed value returned in API responses
// whenever a sensitive field is set but withheld from the client. The empty
// string ("") is reserved for the orthogonal "not set" state, which lets the
// frontend distinguish the two states without an extra boolean field.
//
// The value matches the placeholder already used by
// ConnectionConfig.MaskSensitiveFields on VectorStore responses; this package
// promotes it to a shared constant so every resource agrees on the same
// sentinel.
// whenever a sensitive field is set but withheld from the client. Currently
// used only by VectorStore connection responses. New code should NOT
// introduce redacted-placeholder semantics — model the credential as a
// subresource and omit it from the main response shape instead (see
// internal/handler/dto/mcp.go for the template).
const RedactedSecretPlaceholder = "***"
// IsRedactedOrEmpty reports whether s should be treated as "no change
// requested" in an Update* request. It returns true for:
//
// - "" — the field was absent from the client payload or explicitly cleared
// on the form without using the dedicated Clear* flag
// - RedactedSecretPlaceholder — the client echoed back the value it received
// in a GET response (for example, a legacy frontend that pre-fills the
// edit form with the redacted value)
//
// Any other value is an explicit replacement and must be persisted.
func IsRedactedOrEmpty(s string) bool {
return s == "" || s == RedactedSecretPlaceholder
}
// PreserveIfRedacted returns existing when incoming is empty or the redacted
// placeholder, otherwise returns incoming. Call this from every Update*
// service that accepts secret fields in its request DTO to keep the preserve
// semantics identical across resources.
func PreserveIfRedacted(incoming, existing string) string {
if IsRedactedOrEmpty(incoming) {
return existing
}
return incoming
}

View File

@@ -1,51 +0,0 @@
package types
import "testing"
func TestIsRedactedOrEmpty(t *testing.T) {
tests := []struct {
name string
in string
want bool
}{
{"empty string is redacted-or-empty", "", true},
{"fixed placeholder is redacted-or-empty", RedactedSecretPlaceholder, true},
{"short real secret is not redacted", "ab", false},
{"real secret longer than placeholder is not redacted", "real-bearer-token", false},
{"secret containing placeholder substring is not redacted", "prefix***suffix", false},
{"single asterisk is not redacted", "*", false},
{"double asterisk is not redacted", "**", false},
{"four asterisks is not redacted (distinct from 3)", "****", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsRedactedOrEmpty(tt.in); got != tt.want {
t.Errorf("IsRedactedOrEmpty(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestPreserveIfRedacted(t *testing.T) {
tests := []struct {
name string
incoming string
existing string
want string
}{
{"empty incoming preserves existing", "", "stored-secret", "stored-secret"},
{"placeholder incoming preserves existing", RedactedSecretPlaceholder, "stored-secret", "stored-secret"},
{"real incoming replaces existing", "new-secret", "stored-secret", "new-secret"},
{"real incoming replaces empty existing", "new-secret", "", "new-secret"},
{"empty incoming with empty existing stays empty", "", "", ""},
{"placeholder incoming with empty existing stays empty", RedactedSecretPlaceholder, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := PreserveIfRedacted(tt.incoming, tt.existing); got != tt.want {
t.Errorf("PreserveIfRedacted(%q, %q) = %q, want %q",
tt.incoming, tt.existing, got, tt.want)
}
})
}
}

View File

@@ -65,14 +65,11 @@ func (e *WebSearchProviderEntity) BeforeCreate(tx *gorm.DB) (err error) {
// WebSearchProviderParameters holds provider-specific configuration.
// API keys are encrypted at rest using AES-GCM.
// BaseURL is intentionally NOT included — each provider type uses a hardcoded
// official API endpoint to prevent SSRF attacks.
//
// ClearAPIKey is a write-only flag used in Update requests to explicitly
// remove the stored API key. It is never persisted and never returned in
// responses. Without this flag an empty APIKey in an Update request is
// interpreted as "no change", so this is the only way to intentionally clear
// a stored key without deleting the entire provider record.
// Credential mutation flows through the dedicated /credentials subresource
// (see internal/handler/web_search_provider_credentials.go). Secret fields
// are never returned in responses — handlers serialize via
// dto.NewWebSearchProviderResponse which omits APIKey by construction.
type WebSearchProviderParameters struct {
// API key for the search provider (encrypted in DB)
APIKey string `yaml:"api_key" json:"api_key,omitempty"`
@@ -86,42 +83,6 @@ type WebSearchProviderParameters struct {
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
// Provider-specific extra configuration for future extensibility
ExtraConfig map[string]string `yaml:"extra_config" json:"extra_config,omitempty"`
// Write-only clear flag — never persisted, never returned.
ClearAPIKey bool `yaml:"-" json:"clear_api_key,omitempty" gorm:"-"`
}
// RedactSensitiveData replaces APIKey with RedactedSecretPlaceholder when a
// value is set. Empty values stay empty so the frontend can distinguish
// "set (hidden)" from "not set". Mutates the receiver in place.
func (e *WebSearchProviderEntity) RedactSensitiveData() {
if e.Parameters.APIKey != "" {
e.Parameters.APIKey = RedactedSecretPlaceholder
}
// Write-only flag must never reach the client. omitempty alone is
// insufficient because a true value serializes; explicitly clear it so
// any response path that runs RedactSensitiveData is safe by construction.
e.Parameters.ClearAPIKey = false
}
// MergeUpdate applies write-only secret merge semantics for Update requests:
//
// - ClearAPIKey takes precedence — when true, APIKey becomes empty
// - Otherwise APIKey uses PreserveIfRedacted: empty or the redacted
// placeholder preserves existing, any other value replaces
// - Non-secret fields (EngineID, ProxyURL, ExtraConfig) are replaced
// directly from incoming
// - The write-only ClearAPIKey flag is cleared on the merged result so it
// never leaks to storage
func (p WebSearchProviderParameters) MergeUpdate(existing WebSearchProviderParameters) WebSearchProviderParameters {
merged := p
if p.ClearAPIKey {
merged.APIKey = ""
} else {
merged.APIKey = PreserveIfRedacted(p.APIKey, existing.APIKey)
}
merged.ClearAPIKey = false
return merged
}
// Value implements the driver.Valuer interface.

View File

@@ -1,87 +0,0 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWebSearchProviderEntity_RedactSensitiveData(t *testing.T) {
t.Run("redacts set api key", func(t *testing.T) {
e := &WebSearchProviderEntity{
Parameters: WebSearchProviderParameters{APIKey: "real-key", EngineID: "cse-id"},
}
e.RedactSensitiveData()
assert.Equal(t, RedactedSecretPlaceholder, e.Parameters.APIKey)
assert.Equal(t, "cse-id", e.Parameters.EngineID, "non-secret fields preserved")
})
t.Run("leaves empty api key empty", func(t *testing.T) {
e := &WebSearchProviderEntity{
Parameters: WebSearchProviderParameters{APIKey: "", EngineID: "cse-id"},
}
e.RedactSensitiveData()
assert.Empty(t, e.Parameters.APIKey, "empty must stay empty to signal 'not set' to the frontend")
})
t.Run("zeroes write-only clear flag so it never echoes back", func(t *testing.T) {
// Regression for response-side leak: a true ClearAPIKey serializes
// despite json:"...,omitempty" because true is not a zero value.
e := &WebSearchProviderEntity{
Parameters: WebSearchProviderParameters{APIKey: "real-key", ClearAPIKey: true},
}
e.RedactSensitiveData()
assert.False(t, e.Parameters.ClearAPIKey, "ClearAPIKey must be reset before response")
})
}
func TestWebSearchProviderParameters_MergeUpdate(t *testing.T) {
existing := WebSearchProviderParameters{
APIKey: "stored-key",
EngineID: "stored-engine",
ProxyURL: "http://stored-proxy",
}
t.Run("empty api key preserves existing", func(t *testing.T) {
in := WebSearchProviderParameters{APIKey: "", EngineID: "new-engine"}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-key", out.APIKey)
assert.Equal(t, "new-engine", out.EngineID, "non-secret fields flow from incoming")
})
t.Run("redacted placeholder preserves existing", func(t *testing.T) {
in := WebSearchProviderParameters{APIKey: RedactedSecretPlaceholder}
out := in.MergeUpdate(existing)
assert.Equal(t, "stored-key", out.APIKey)
})
t.Run("real value replaces existing", func(t *testing.T) {
in := WebSearchProviderParameters{APIKey: "new-key"}
out := in.MergeUpdate(existing)
assert.Equal(t, "new-key", out.APIKey)
})
t.Run("ClearAPIKey wipes stored value", func(t *testing.T) {
in := WebSearchProviderParameters{ClearAPIKey: true}
out := in.MergeUpdate(existing)
assert.Empty(t, out.APIKey)
})
t.Run("ClearAPIKey takes precedence over submitted value", func(t *testing.T) {
in := WebSearchProviderParameters{APIKey: "will-be-ignored", ClearAPIKey: true}
out := in.MergeUpdate(existing)
assert.Empty(t, out.APIKey)
})
t.Run("ClearAPIKey flag never persists on merged result", func(t *testing.T) {
in := WebSearchProviderParameters{ClearAPIKey: true}
out := in.MergeUpdate(existing)
assert.False(t, out.ClearAPIKey, "write-only flag must not leak to storage")
})
t.Run("merge against empty existing", func(t *testing.T) {
in := WebSearchProviderParameters{APIKey: RedactedSecretPlaceholder}
out := in.MergeUpdate(WebSearchProviderParameters{})
assert.Empty(t, out.APIKey, "placeholder against empty preserves empty")
})
}