fix(security): 屏蔽 admin 账号接口返回的敏感凭证字段
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_<key> 布尔标识)告知前端存在性,不暴露原值。 - 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) <noreply@anthropic.com>
This commit is contained in:
parent
a466e80ed6
commit
0f8e2d0934
@ -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"`
|
||||
|
||||
67
backend/internal/handler/dto/account_mapper_redact_test.go
Normal file
67
backend/internal/handler/dto/account_mapper_redact_test.go
Normal file
@ -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)
|
||||
}
|
||||
44
backend/internal/handler/dto/credentials_redact.go
Normal file
44
backend/internal/handler/dto/credentials_redact.go
Normal file
@ -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_<key> 状态 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
|
||||
}
|
||||
}
|
||||
97
backend/internal/handler/dto/credentials_redact_test.go
Normal file
97
backend/internal/handler/dto/credentials_redact_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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_<key>)暴露,原始值不返回前端。
|
||||
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"`
|
||||
|
||||
|
||||
50
backend/internal/service/account_credentials_redact.go
Normal file
50
backend/internal/service/account_credentials_redact.go
Normal file
@ -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
|
||||
}
|
||||
90
backend/internal/service/account_credentials_redact_test.go
Normal file
90
backend/internal/service/account_credentials_redact_test.go
Normal file
@ -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"))
|
||||
}
|
||||
@ -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:{},此时也必须落库。
|
||||
|
||||
117
backend/internal/service/admin_service_credentials_merge_test.go
Normal file
117
backend/internal/service/admin_service_credentials_merge_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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_<key> 暴露存在性。
|
||||
credentials?: Record<string, unknown>
|
||||
credentials_status?: Record<string, boolean>
|
||||
// Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits.
|
||||
extra?: (CodexUsageSnapshot & OpenAICompactState & {
|
||||
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user