From 0f8e2d0934434e5c99d2c2a0b128eb07b6586f1d Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Fri, 8 May 2026 03:43:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(security):=20=E5=B1=8F=E8=94=BD=20admin?= =?UTF-8?q?=20=E8=B4=A6=E5=8F=B7=E6=8E=A5=E5=8F=A3=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E7=9A=84=E6=95=8F=E6=84=9F=E5=87=AD=E8=AF=81=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Account.Credentials 是 JSONB map,混合存放可编辑的非敏感配置(base_url、 model_mapping、project_id 等)与敏感秘钥(OAuth access/refresh/id token、 API key、AWS secret、Vertex private key 等)。当前所有 admin 账号接口直接 透传该 map,token 经由浏览器 DevTools、抓包、日志等途径泄漏。 - service 包新增 SensitiveCredentialKeys 清单与 MergePreservingSensitiveCreds 作为单一权威定义。 - dto 层 RedactCredentials 在响应里剥离敏感子键,输出 credentials_status (has_ 布尔标识)告知前端存在性,不暴露原值。 - AccountFromServiceShallow 接入脱敏,覆盖 list、get、create、update、 refresh、batch、bulk-update、OAuth 创建等 9 个 handler。 - service.UpdateAccount 改为合并语义:incoming 没传敏感键则保留 existing, 让前端"全对象 PUT"流程在脱敏后无感工作;显式提供新 token 仍会覆盖。 - 前端 EditAccountModal 修复脱敏后会崩的两处兜底:apikey 必填检查和 Vertex SA JSON 存在性校验改读 credentials_status.has_*。 - 导出端点 /admin/accounts/data 走独立的 DataAccount 结构,按设计保留 完整 credentials 作为管理员备份路径。 测试:RedactCredentials 单元测试、mapper 端到端 JSON 断言(确认序列化 后无 token 子串)、UpdateAccount 合并语义三种场景(保留 / 覆盖 / 空 map 跳过)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handler/admin/account_data.go | 3 + .../handler/dto/account_mapper_redact_test.go | 67 ++++++++++ .../handler/dto/credentials_redact.go | 44 +++++++ .../handler/dto/credentials_redact_test.go | 97 +++++++++++++++ backend/internal/handler/dto/mappers.go | 4 +- backend/internal/handler/dto/types.go | 41 +++--- .../service/account_credentials_redact.go | 50 ++++++++ .../account_credentials_redact_test.go | 90 ++++++++++++++ backend/internal/service/admin_service.go | 4 +- .../admin_service_credentials_merge_test.go | 117 ++++++++++++++++++ .../components/account/EditAccountModal.vue | 15 ++- frontend/src/types/index.ts | 5 + 12 files changed, 510 insertions(+), 27 deletions(-) create mode 100644 backend/internal/handler/dto/account_mapper_redact_test.go create mode 100644 backend/internal/handler/dto/credentials_redact.go create mode 100644 backend/internal/handler/dto/credentials_redact_test.go create mode 100644 backend/internal/service/account_credentials_redact.go create mode 100644 backend/internal/service/account_credentials_redact_test.go create mode 100644 backend/internal/service/admin_service_credentials_merge_test.go diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index 00da4821..50beadf6 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -43,6 +43,9 @@ type DataProxy struct { Status string `json:"status"` } +// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径, +// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本, +// 应新增独立结构而非修改这里。 type DataAccount struct { Name string `json:"name"` Notes *string `json:"notes,omitempty"` diff --git a/backend/internal/handler/dto/account_mapper_redact_test.go b/backend/internal/handler/dto/account_mapper_redact_test.go new file mode 100644 index 00000000..bd584e11 --- /dev/null +++ b/backend/internal/handler/dto/account_mapper_redact_test.go @@ -0,0 +1,67 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func TestAccountFromServiceShallow_RedactsSensitiveCredentials(t *testing.T) { + src := &service.Account{ + ID: 42, + Name: "demo", + Platform: "anthropic", + Type: "oauth", + Credentials: map[string]any{ + "access_token": "at-secret", + "refresh_token": "rt-secret", + "id_token": "id-secret", + "api_key": "sk-secret", + "base_url": "https://api.example.com", + "model_mapping": map[string]any{"foo": "bar"}, + }, + } + + got := AccountFromServiceShallow(src) + require.NotNil(t, got) + + // 敏感键不在 Credentials 里 + require.NotContains(t, got.Credentials, "access_token") + require.NotContains(t, got.Credentials, "refresh_token") + require.NotContains(t, got.Credentials, "id_token") + require.NotContains(t, got.Credentials, "api_key") + // 非敏感键保留 + require.Equal(t, "https://api.example.com", got.Credentials["base_url"]) + require.Equal(t, map[string]any{"foo": "bar"}, got.Credentials["model_mapping"]) + + // 状态 map 标记敏感键存在 + require.True(t, got.CredentialsStatus["has_access_token"]) + require.True(t, got.CredentialsStatus["has_refresh_token"]) + require.True(t, got.CredentialsStatus["has_id_token"]) + require.True(t, got.CredentialsStatus["has_api_key"]) + + // JSON 序列化校验:响应体里不会出现敏感子串 + raw, err := json.Marshal(got) + require.NoError(t, err) + require.NotContains(t, string(raw), "rt-secret") + require.NotContains(t, string(raw), "at-secret") + require.NotContains(t, string(raw), "sk-secret") + require.NotContains(t, string(raw), "id-secret") + // 状态标识应序列化进 JSON + require.Contains(t, string(raw), "credentials_status") + require.Contains(t, string(raw), "has_refresh_token") + + // 原始 service.Account 不应被改动 + require.Equal(t, "rt-secret", src.Credentials["refresh_token"]) +} + +func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) { + src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"} + got := AccountFromServiceShallow(src) + require.NotNil(t, got) + require.Nil(t, got.Credentials) + require.Nil(t, got.CredentialsStatus) +} diff --git a/backend/internal/handler/dto/credentials_redact.go b/backend/internal/handler/dto/credentials_redact.go new file mode 100644 index 00000000..e65a8007 --- /dev/null +++ b/backend/internal/handler/dto/credentials_redact.go @@ -0,0 +1,44 @@ +// Package dto provides data transfer objects for HTTP handlers. +package dto + +import "github.com/Wei-Shaw/sub2api/internal/service" + +// RedactCredentials 复制一份 in,剥离 service.SensitiveCredentialKeys 列出的所有敏感子键, +// 并产出一个 has_ 状态 map 表示哪些敏感键存在且非零值。 +// +// 输入 nil 时返回 nil, nil(避免响应里出现空对象)。 +// 不修改入参;调用方拿到的 out 可安全序列化进 JSON 返回前端。 +func RedactCredentials(in map[string]any) (out map[string]any, status map[string]bool) { + if in == nil { + return nil, nil + } + out = make(map[string]any, len(in)) + for k, v := range in { + if service.IsSensitiveCredentialKey(k) { + if isCredentialValuePresent(v) { + if status == nil { + status = make(map[string]bool, 4) + } + status["has_"+k] = true + } + continue + } + out[k] = v + } + return out, status +} + +// isCredentialValuePresent 判断值是否"存在且非零"。空字符串、nil、false 均视为未配置; +// 其余非零类型(数字、对象、字符串等)视为已配置。 +func isCredentialValuePresent(v any) bool { + switch x := v.(type) { + case nil: + return false + case string: + return x != "" + case bool: + return x + default: + return true + } +} diff --git a/backend/internal/handler/dto/credentials_redact_test.go b/backend/internal/handler/dto/credentials_redact_test.go new file mode 100644 index 00000000..431078fa --- /dev/null +++ b/backend/internal/handler/dto/credentials_redact_test.go @@ -0,0 +1,97 @@ +package dto + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRedactCredentials_NilInput(t *testing.T) { + out, status := RedactCredentials(nil) + require.Nil(t, out) + require.Nil(t, status) +} + +func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) { + in := map[string]any{ + "refresh_token": "rt-secret", + "access_token": "at-secret", + "api_key": "sk-secret", + "aws_secret_access_key": "aws-secret", + "service_account_json": map[string]any{"private_key": "..."}, + "private_key": "raw-key", + // 非敏感 + "base_url": "https://api.example.com", + "model_mapping": map[string]any{"foo": "bar"}, + "project_id": "proj-1", + "expires_at": int64(123456), + } + + out, status := RedactCredentials(in) + + require.NotContains(t, out, "refresh_token") + require.NotContains(t, out, "access_token") + require.NotContains(t, out, "api_key") + require.NotContains(t, out, "aws_secret_access_key") + require.NotContains(t, out, "service_account_json") + require.NotContains(t, out, "private_key") + + require.Equal(t, "https://api.example.com", out["base_url"]) + require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"]) + require.Equal(t, "proj-1", out["project_id"]) + require.Equal(t, int64(123456), out["expires_at"]) + + require.True(t, status["has_refresh_token"]) + require.True(t, status["has_access_token"]) + require.True(t, status["has_api_key"]) + require.True(t, status["has_aws_secret_access_key"]) + require.True(t, status["has_service_account_json"]) + require.True(t, status["has_private_key"]) + + // 状态 map 不应携带非敏感键的 has_* + require.NotContains(t, status, "has_base_url") + require.NotContains(t, status, "has_project_id") +} + +func TestRedactCredentials_EmptyValuesNotMarkedPresent(t *testing.T) { + in := map[string]any{ + "refresh_token": "", + "access_token": nil, + "api_key": false, + "id_token": "actual-id", + } + out, status := RedactCredentials(in) + require.Empty(t, out, "敏感键即使为空也不应出现在 redacted output") + require.False(t, status["has_refresh_token"]) + require.False(t, status["has_access_token"]) + require.False(t, status["has_api_key"]) + require.True(t, status["has_id_token"]) +} + +func TestRedactCredentials_DoesNotMutateInput(t *testing.T) { + in := map[string]any{ + "refresh_token": "secret", + "base_url": "x", + } + _, _ = RedactCredentials(in) + require.Equal(t, "secret", in["refresh_token"], "原始 map 不应被修改") + require.Equal(t, "x", in["base_url"]) +} + +func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) { + keys := []string{ + "access_token", "refresh_token", "id_token", + "api_key", "session_key", "cookie", + "aws_secret_access_key", "aws_session_token", + "service_account_json", "service_account", "private_key", + } + in := make(map[string]any, len(keys)) + for _, k := range keys { + in[k] = "filled" + } + out, status := RedactCredentials(in) + require.Empty(t, out) + for _, k := range keys { + require.True(t, status["has_"+k], "key %s 应在 status 中标记为已配置", k) + } +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2559b112..c6e79922 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -198,13 +198,15 @@ func AccountFromServiceShallow(a *service.Account) *Account { if a == nil { return nil } + redactedCreds, credsStatus := RedactCredentials(a.Credentials) out := &Account{ ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, - Credentials: a.Credentials, + Credentials: redactedCreds, + CredentialsStatus: credsStatus, Extra: a.Extra, ProxyID: a.ProxyID, Concurrency: a.Concurrency, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index e15a916e..168f9375 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -149,25 +149,28 @@ type AdminGroup struct { } type Account struct { - ID int64 `json:"id"` - Name string `json:"name"` - Notes *string `json:"notes"` - Platform string `json:"platform"` - Type string `json:"type"` - Credentials map[string]any `json:"credentials"` - Extra map[string]any `json:"extra"` - ProxyID *int64 `json:"proxy_id"` - Concurrency int `json:"concurrency"` - LoadFactor *int `json:"load_factor,omitempty"` - Priority int `json:"priority"` - RateMultiplier float64 `json:"rate_multiplier"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - LastUsedAt *time.Time `json:"last_used_at"` - ExpiresAt *int64 `json:"expires_at"` - AutoPauseOnExpired bool `json:"auto_pause_on_expired"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + // Credentials 经 RedactCredentials 处理后只含非敏感子键;敏感 token / api_key / 私钥 + // 的存在性通过 CredentialsStatus(has_)暴露,原始值不返回前端。 + Credentials map[string]any `json:"credentials"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra"` + ProxyID *int64 `json:"proxy_id"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Schedulable bool `json:"schedulable"` diff --git a/backend/internal/service/account_credentials_redact.go b/backend/internal/service/account_credentials_redact.go new file mode 100644 index 00000000..76c2d1de --- /dev/null +++ b/backend/internal/service/account_credentials_redact.go @@ -0,0 +1,50 @@ +package service + +// SensitiveCredentialKeys 列出 Account.Credentials JSON map 中绝不允许返回到前端的子键。 +// dto 层做响应脱敏、service 层做更新合并都引用此清单——新增凭证类型时务必同步。 +var SensitiveCredentialKeys = []string{ + // OAuth + "access_token", "refresh_token", "id_token", + // API Key 类 + "api_key", "session_key", "cookie", + // 云服务凭据 + "aws_secret_access_key", "aws_session_token", + "service_account_json", "service_account", "private_key", +} + +var sensitiveCredentialKeySet = func() map[string]struct{} { + m := make(map[string]struct{}, len(SensitiveCredentialKeys)) + for _, k := range SensitiveCredentialKeys { + m[k] = struct{}{} + } + return m +}() + +// IsSensitiveCredentialKey 判断指定键是否为敏感凭证子键。 +func IsSensitiveCredentialKey(key string) bool { + _, ok := sensitiveCredentialKeySet[key] + return ok +} + +// MergePreservingSensitiveCreds 把 incoming 写入 existing 之上,但敏感子键采用"incoming 没提供就保留 existing" +// 的语义。返回新的 map,不修改入参。 +// +// 用途:前端编辑账号通常采用"全对象 PUT"模式;脱敏后前端 spread 旧 credentials 时不会带上敏感键, +// 直接覆盖会清空已有 token。此函数保证: +// - 非敏感键:完全由 incoming 决定(用户可以编辑、删除非敏感字段)。 +// - 敏感键:incoming 显式提供则覆盖(用户主动旋转 token),否则保留 existing。 +func MergePreservingSensitiveCreds(existing, incoming map[string]any) map[string]any { + out := make(map[string]any, len(incoming)+len(SensitiveCredentialKeys)) + for k, v := range incoming { + out[k] = v + } + for _, key := range SensitiveCredentialKeys { + if _, hasIncoming := incoming[key]; hasIncoming { + continue + } + if existingVal, ok := existing[key]; ok { + out[key] = existingVal + } + } + return out +} diff --git a/backend/internal/service/account_credentials_redact_test.go b/backend/internal/service/account_credentials_redact_test.go new file mode 100644 index 00000000..05f37da9 --- /dev/null +++ b/backend/internal/service/account_credentials_redact_test.go @@ -0,0 +1,90 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergePreservingSensitiveCreds_PreservesSensitiveWhenIncomingMissing(t *testing.T) { + existing := map[string]any{ + "refresh_token": "rt-old", + "access_token": "at-old", + "api_key": "sk-old", + "base_url": "https://old.example.com", + } + incoming := map[string]any{ + "base_url": "https://new.example.com", + "model_mapping": map[string]any{"foo": "bar"}, + } + + out := MergePreservingSensitiveCreds(existing, incoming) + + require.Equal(t, "rt-old", out["refresh_token"], "incoming 没传 refresh_token,应保留 existing") + require.Equal(t, "at-old", out["access_token"]) + require.Equal(t, "sk-old", out["api_key"]) + require.Equal(t, "https://new.example.com", out["base_url"], "非敏感键由 incoming 决定") + require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"]) +} + +func TestMergePreservingSensitiveCreds_OverwritesWhenIncomingProvidesSensitive(t *testing.T) { + existing := map[string]any{ + "refresh_token": "rt-old", + "api_key": "sk-old", + } + incoming := map[string]any{ + "refresh_token": "rt-new", + // 显式没传 api_key —— 应保留 + } + out := MergePreservingSensitiveCreds(existing, incoming) + require.Equal(t, "rt-new", out["refresh_token"], "incoming 显式传入应覆盖") + require.Equal(t, "sk-old", out["api_key"], "incoming 没传应保留") +} + +func TestMergePreservingSensitiveCreds_DoesNotMutateInputs(t *testing.T) { + existing := map[string]any{"refresh_token": "rt"} + incoming := map[string]any{"base_url": "x"} + + _ = MergePreservingSensitiveCreds(existing, incoming) + + require.Equal(t, "rt", existing["refresh_token"]) + require.NotContains(t, existing, "base_url") + require.Equal(t, "x", incoming["base_url"]) + require.NotContains(t, incoming, "refresh_token") +} + +func TestMergePreservingSensitiveCreds_NilInputs(t *testing.T) { + out := MergePreservingSensitiveCreds(nil, map[string]any{"base_url": "x"}) + require.Equal(t, "x", out["base_url"]) + require.NotContains(t, out, "refresh_token") + + out2 := MergePreservingSensitiveCreds(map[string]any{"refresh_token": "rt"}, nil) + require.Equal(t, "rt", out2["refresh_token"]) +} + +func TestMergePreservingSensitiveCreds_NonSensitiveDeletionAllowed(t *testing.T) { + existing := map[string]any{ + "refresh_token": "rt", + "base_url": "https://old", + "project_id": "p1", + } + incoming := map[string]any{ + "base_url": "https://new", + // 不带 project_id —— 等同删除(非敏感键由 incoming 决定) + } + out := MergePreservingSensitiveCreds(existing, incoming) + require.Equal(t, "rt", out["refresh_token"], "敏感键保留") + require.Equal(t, "https://new", out["base_url"]) + require.NotContains(t, out, "project_id", "非敏感键 incoming 不传 = 删除") +} + +func TestIsSensitiveCredentialKey(t *testing.T) { + require.True(t, IsSensitiveCredentialKey("refresh_token")) + require.True(t, IsSensitiveCredentialKey("api_key")) + require.True(t, IsSensitiveCredentialKey("private_key")) + require.False(t, IsSensitiveCredentialKey("base_url")) + require.False(t, IsSensitiveCredentialKey("")) + require.False(t, IsSensitiveCredentialKey("model_mapping")) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index eb5994d5..ff65fdec 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2470,7 +2470,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U account.Notes = normalizeAccountNotes(input.Notes) } if len(input.Credentials) > 0 { - account.Credentials = input.Credentials + // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, + // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) } // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 diff --git a/backend/internal/service/admin_service_credentials_merge_test.go b/backend/internal/service/admin_service_credentials_merge_test.go new file mode 100644 index 00000000..8250db28 --- /dev/null +++ b/backend/internal/service/admin_service_credentials_merge_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +type updateAccountCredsRepoStub struct { + mockAccountRepoForGemini + account *Account + updateCalls int +} + +func (r *updateAccountCredsRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) { + return r.account, nil +} + +func (r *updateAccountCredsRepoStub) Update(ctx context.Context, account *Account) error { + r.updateCalls++ + r.account = account + return nil +} + +func TestUpdateAccount_PreservesSensitiveCredsWhenIncomingOmits(t *testing.T) { + accountID := int64(202) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "refresh_token": "rt-existing", + "access_token": "at-existing", + "id_token": "id-existing", + "base_url": "https://old.example.com", + }, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + // 模拟前端编辑:仅修改 base_url,没有传 token(脱敏后前端 spread 拿不到敏感键) + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{ + "base_url": "https://new.example.com", + }, + }) + + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, 1, repo.updateCalls) + + // 敏感键应保留 + require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"]) + require.Equal(t, "at-existing", repo.account.Credentials["access_token"]) + require.Equal(t, "id-existing", repo.account.Credentials["id_token"]) + // 非敏感键被替换 + require.Equal(t, "https://new.example.com", repo.account.Credentials["base_url"]) +} + +func TestUpdateAccount_ExplicitNewTokenOverwrites(t *testing.T) { + accountID := int64(203) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "refresh_token": "rt-old", + "api_key": "sk-old", + }, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{ + "refresh_token": "rt-new", + // api_key 没传 → 应保留旧值 + }, + }) + require.NoError(t, err) + require.NotNil(t, updated) + + require.Equal(t, "rt-new", repo.account.Credentials["refresh_token"]) + require.Equal(t, "sk-old", repo.account.Credentials["api_key"]) +} + +func TestUpdateAccount_EmptyCredentialsSkipsUpdate(t *testing.T) { + accountID := int64(204) + repo := &updateAccountCredsRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{ + "refresh_token": "rt-existing", + }, + }, + } + svc := &adminServiceImpl{accountRepo: repo} + + _, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Credentials: map[string]any{}, // len == 0 → 闸门跳过 + Name: "renamed", + }) + require.NoError(t, err) + + require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"], "空 credentials 不应触碰已有 token") + require.Equal(t, "renamed", repo.account.Name) +} diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 80f0b890..059813c9 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3343,13 +3343,12 @@ const handleSubmit = async () => { } // Handle API key + // 后端响应已脱敏:currentCredentials 不会再包含 api_key 原文。 + // 用户填入新值则覆盖;留空且后端已存在旧 key(看 credentials_status)则不带 api_key 字段, + // 由后端合并保留;两者都无才报错。 if (editApiKey.value.trim()) { - // User provided a new API key newCredentials.api_key = editApiKey.value.trim() - } else if (currentCredentials.api_key) { - // Preserve existing api_key - newCredentials.api_key = currentCredentials.api_key - } else { + } else if (!props.account.credentials_status?.has_api_key) { appStore.showError(t('admin.accounts.apiKeyIsRequired')) return } @@ -3434,7 +3433,11 @@ const handleSubmit = async () => { return } - if (!currentCredentials.service_account_json && !currentCredentials.service_account) { + // SA JSON 已脱敏不再随 credentials 返回,存在性改读 credentials_status。 + const hasExistingServiceAccountJson = + props.account.credentials_status?.has_service_account_json || + props.account.credentials_status?.has_service_account + if (!hasExistingServiceAccountJson) { appStore.showError(t('admin.accounts.vertexSaJsonRequired')) return } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 328b7c04..756da1b9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -795,7 +795,12 @@ export interface Account { notes?: string | null platform: AccountPlatform type: AccountType + // 后端响应里 credentials 已脱敏:access_token / refresh_token / id_token / + // api_key / session_key / cookie / aws_secret_access_key / aws_session_token / + // service_account_json / service_account / private_key 不会出现, + // 改为通过 credentials_status.has_ 暴露存在性。 credentials?: Record + credentials_status?: Record // Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits. extra?: (CodexUsageSnapshot & OpenAICompactState & { model_rate_limits?: Record From 3ca232ad0611b883ed25ad4e878c399abcfa5851 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 17 May 2026 03:02:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(frontend):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=9B=9E=E9=80=80=E6=97=A7=20credentials=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BB=A5=E5=85=BC=E5=AE=B9=E6=97=A7=E5=90=8E?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新代码仅依赖 credentials_status 会导致两种灰度场景被误判为未配置: - 新前端 + 旧后端:旧后端未返回 credentials_status,前端读不到已脱敏的 api_key / service_account_json,阻止保存。 - 旧前端 + 新后端:旧前端也读不到已脱敏字段(旧前端不在本 PR 范围)。 修复: - API key 判断改为 credentials_status?.has_api_key ?? Boolean(currentCredentials.api_key) - Vertex SA 判断:有 credentials_status 用 status,否则回退读 credentials.service_account_json / service_account 补充测试覆盖: - apikey/Vertex SA 各自的新后端脱敏响应、旧后端未脱敏响应、 两者皆缺时阻止保存。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/account/EditAccountModal.vue | 21 ++- .../__tests__/EditAccountModal.spec.ts | 144 ++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 059813c9..029c5819 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3344,11 +3344,14 @@ const handleSubmit = async () => { // Handle API key // 后端响应已脱敏:currentCredentials 不会再包含 api_key 原文。 - // 用户填入新值则覆盖;留空且后端已存在旧 key(看 credentials_status)则不带 api_key 字段, - // 由后端合并保留;两者都无才报错。 + // 用户填入新值则覆盖;留空时优先看 credentials_status.has_api_key; + // 若后端尚未升级(无 credentials_status),回退读旧结构 currentCredentials.api_key。 + // 两者都无才报错。 + const hasExistingApiKey = + props.account.credentials_status?.has_api_key ?? Boolean(currentCredentials.api_key) if (editApiKey.value.trim()) { newCredentials.api_key = editApiKey.value.trim() - } else if (!props.account.credentials_status?.has_api_key) { + } else if (!hasExistingApiKey) { appStore.showError(t('admin.accounts.apiKeyIsRequired')) return } @@ -3433,10 +3436,14 @@ const handleSubmit = async () => { return } - // SA JSON 已脱敏不再随 credentials 返回,存在性改读 credentials_status。 - const hasExistingServiceAccountJson = - props.account.credentials_status?.has_service_account_json || - props.account.credentials_status?.has_service_account + // SA JSON 已脱敏不再随 credentials 返回,存在性优先读 credentials_status。 + // 若后端尚未升级(无 credentials_status),回退读旧结构 service_account_json / service_account。 + const credentialsStatus = props.account.credentials_status + const hasExistingServiceAccountJson = credentialsStatus + ? Boolean( + credentialsStatus.has_service_account_json || credentialsStatus.has_service_account + ) + : Boolean(currentCredentials.service_account_json || currentCredentials.service_account) if (!hasExistingServiceAccountJson) { appStore.showError(t('admin.accounts.vertexSaJsonRequired')) return diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 04486154..db7ea653 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -141,6 +141,32 @@ function buildAccount() { } as any } +function buildVertexAccount() { + return { + id: 2, + name: 'Vertex SA', + notes: '', + platform: 'gemini', + type: 'service_account', + credentials: { + service_account_json: '{"type":"service_account","client_email":"sa@example.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\\nMIIE\\n-----END PRIVATE KEY-----\\n"}', + project_id: 'demo-project', + client_email: 'sa@example.iam.gserviceaccount.com', + location: 'us-central1', + tier_id: 'vertex' + }, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + rate_multiplier: 1, + status: 'active', + group_ids: [], + expires_at: null, + auto_pause_on_expired: false + } as any +} + function mountModal(account = buildAccount()) { return mount(EditAccountModal, { props: { @@ -237,4 +263,122 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true) expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled') }) + + it('allows saving apikey account when backend redacted api_key but credentials_status reports it exists', async () => { + // 新前端 + 新后端:响应已脱敏,credentials 里没有 api_key,credentials_status.has_api_key=true + const account = buildAccount() + account.credentials = { + base_url: 'https://api.openai.com', + model_mapping: { 'gpt-5.2': 'gpt-5.2' } + } + account.credentials_status = { has_api_key: true } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + // 用户未输入新 key 时,payload 不应带 api_key,由后端合并保留旧值 + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty('api_key') + }) + + it('allows saving apikey account against legacy backend without credentials_status', async () => { + // 新前端 + 旧后端:credentials_status 缺失,但 credentials.api_key 仍是明文,应允许保存 + const account = buildAccount() + // 显式确保没有 credentials_status + expect(account.credentials_status).toBeUndefined() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + // 旧后端响应未脱敏,原 api_key 会随 currentCredentials 一起传回去(旧行为,等价于无操作) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.api_key).toBe('sk-test') + }) + + it('blocks apikey save when neither credentials_status nor legacy api_key indicates existence', async () => { + const account = buildAccount() + account.credentials = { + base_url: 'https://api.openai.com' + } + // 既没有 credentials_status 也没有旧的 api_key + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).not.toHaveBeenCalled() + }) + + it('allows saving Vertex SA account when backend redacted service_account_json but credentials_status reports it exists', async () => { + // 新前端 + 新后端:响应已脱敏,credentials 里没有 service_account_json,credentials_status.has_service_account_json=true + const account = buildVertexAccount() + account.credentials = { + project_id: 'demo-project', + client_email: 'sa@example.iam.gserviceaccount.com', + location: 'us-central1', + tier_id: 'vertex' + } + account.credentials_status = { has_service_account_json: true } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.project_id).toBe('demo-project') + }) + + it('allows saving Vertex SA account against legacy backend without credentials_status', async () => { + // 新前端 + 旧后端:credentials_status 缺失,但 credentials.service_account_json 仍是明文,应允许保存 + const account = buildVertexAccount() + expect(account.credentials_status).toBeUndefined() + expect(account.credentials.service_account_json).toBeTruthy() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + }) + + it('blocks Vertex SA save when neither credentials_status nor legacy json indicates existence', async () => { + const account = buildVertexAccount() + account.credentials = { + project_id: 'demo-project', + client_email: 'sa@example.iam.gserviceaccount.com', + location: 'us-central1', + tier_id: 'vertex' + } + // 既没有 credentials_status 也没有旧的 service_account_json + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).not.toHaveBeenCalled() + }) })