diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index a668f578cb..6ab4446ba4 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -708,8 +708,11 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre SET credentials = $1::jsonb, extra = CASE + -- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止 + -- 非 Ollama 账号的无变化持久化误清 openai 探测快照或重写 NULL extra。 WHEN platform IN ('openai', 'anthropic') AND type = 'apikey' + AND credentials IS DISTINCT FROM $1::jsonb AND ( credentials -> 'api_key' IS DISTINCT FROM $1::jsonb -> 'api_key' OR NOT ( diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go index f261323439..a7dc861249 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_integration_test.go @@ -360,3 +360,56 @@ func TestProxyIdentityUpdateInvalidatesOllamaSnapshotAndRejectsInFlightCAS(t *te }) require.ErrorIs(t, err, service.ErrOllamaCloudUsageIdentityChanged) } + +// 无变化的凭证持久化(如 CRS 同步重放同一凭证)不得触发任何 extra 清理; +// 真实变化仍必须按旧语义清 openai 探测快照。 +func TestUpdateCredentialsUnchangedCredentialsPreserveManagedExtra(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + + probeAccount := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "openai-probe-unchanged", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-probe", "base_url": "https://relay.example.com/v1"}, + Extra: map[string]any{ + service.UpstreamBillingProbeEnabledExtraKey: true, + service.UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, + }, + }) + require.NoError(t, repo.UpdateCredentials(ctx, probeAccount.ID, map[string]any{ + "api_key": "sk-probe", "base_url": "https://relay.example.com/v1", + })) + probeLoaded, err := repo.GetByID(ctx, probeAccount.ID) + require.NoError(t, err) + require.Contains(t, probeLoaded.Extra, service.UpstreamBillingProbeExtraKey, + "unchanged credentials must not clear the probe snapshot") + + now := time.Now().UTC() + ollamaAccount := mustCreateAccount(t, tx.Client(), &service.Account{ + Name: "ollama-unchanged", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "ollama-key", "base_url": "https://ollama.com"}, + Extra: map[string]any{ + service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture", + service.OllamaCloudUsageAutoRefreshExtraKey: true, + service.OllamaCloudUsageSnapshotExtraKey: map[string]any{ + "status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour), + }, + }, + }) + require.NoError(t, repo.UpdateCredentials(ctx, ollamaAccount.ID, map[string]any{ + "api_key": "ollama-key", "base_url": "https://ollama.com", + })) + ollamaLoaded, err := repo.GetByID(ctx, ollamaAccount.ID) + require.NoError(t, err) + require.Equal(t, "cipher:wos-session=fixture", ollamaLoaded.Extra[service.OllamaCloudUsageSessionExtraKey]) + require.Equal(t, true, ollamaLoaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey]) + require.Contains(t, ollamaLoaded.Extra, service.OllamaCloudUsageSnapshotExtraKey) + + require.NoError(t, repo.UpdateCredentials(ctx, probeAccount.ID, map[string]any{ + "api_key": "sk-probe", "base_url": "https://relay.example.org/v1", + })) + probeLoaded, err = repo.GetByID(ctx, probeAccount.ID) + require.NoError(t, err) + require.NotContains(t, probeLoaded.Extra, service.UpstreamBillingProbeExtraKey, + "changed credentials must keep clearing the probe snapshot") +} diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go index a7f5bb92a4..07b67287ba 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go @@ -277,3 +277,25 @@ func TestDisableOllamaCloudUsageAutoRefreshUsesGroupIdentityCAS(t *testing.T) { require.NoError(t, err) require.NoError(t, mock.ExpectationsWereMet()) } + +// Ollama 清理分支必须带顶层 credentials DISTINCT 守卫:没有它,非 Ollama 的 +// openai/anthropic apikey 账号在凭证未变化的持久化上也会误清探测快照。 +func TestUpdateCredentialsCleanupBranchRequiresChangedCredentials(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts.*CASE.*AND credentials IS DISTINCT FROM \$1::jsonb\s+AND \(\s+credentials -> 'api_key' IS DISTINCT FROM`). + WithArgs(`{"api_key":"same-key","base_url":"https://relay.example.com/v1"}`, int64(17)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err := repo.UpdateCredentials(context.Background(), 17, map[string]any{ + "api_key": "same-key", "base_url": "https://relay.example.com/v1", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/server/middleware/audit_log.go b/backend/internal/server/middleware/audit_log.go index c07deea49f..98cfff1b44 100644 --- a/backend/internal/server/middleware/audit_log.go +++ b/backend/internal/server/middleware/audit_log.go @@ -146,13 +146,14 @@ var auditActionOverrides = map[string]string{ // auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。 // 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。 var auditBodyOmittedRoutes = map[string]struct{}{ - "POST /api/v1/admin/accounts/import/codex-session": {}, - "PUT /api/v1/admin/prompt-audit/config": {}, - "POST /api/v1/admin/prompt-audit/endpoints/probe": {}, - "DELETE /api/v1/admin/prompt-audit/events/:id": {}, - "POST /api/v1/admin/prompt-audit/events/batch-delete": {}, - "POST /api/v1/admin/prompt-audit/events/delete-preview": {}, - "POST /api/v1/admin/prompt-audit/events/delete-by-filter": {}, + "POST /api/v1/admin/accounts/import/codex-session": {}, + "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {}, + "PUT /api/v1/admin/prompt-audit/config": {}, + "POST /api/v1/admin/prompt-audit/endpoints/probe": {}, + "DELETE /api/v1/admin/prompt-audit/events/:id": {}, + "POST /api/v1/admin/prompt-audit/events/batch-delete": {}, + "POST /api/v1/admin/prompt-audit/events/delete-preview": {}, + "POST /api/v1/admin/prompt-audit/events/delete-by-filter": {}, } // NewAuditLogMiddleware 创建审计中间件。 diff --git a/backend/internal/server/middleware/audit_log_test.go b/backend/internal/server/middleware/audit_log_test.go index 026e9163bf..4892fc6d0b 100644 --- a/backend/internal/server/middleware/audit_log_test.go +++ b/backend/internal/server/middleware/audit_log_test.go @@ -145,3 +145,40 @@ func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing require.Truef(t, omitted, "%s must not persist its credential or confirmation-bearing body", route) } } + +// Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键 +// "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。 +func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) { + gin.SetMode(gin.TestMode) + require.Contains(t, auditBodyOmittedRoutes, "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session") + + repository := &auditCaptureRepository{} + auditService := service.NewAuditLogService(repository, nil) + auditService.Start() + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: 77}) + c.Set(string(ContextKeyUserRole), "admin") + c.Next() + }) + router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService))) + router.PUT("/api/v1/admin/accounts/:id/ollama-cloud-usage/session", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + request := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/7/ollama-cloud-usage/session", + bytes.NewBufferString(`{"session":"wos-session=audit-canary-cookie; __Secure-authjs.session-token.0=audit-canary-shard"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code) + auditService.Stop() + + repository.mu.Lock() + logs := append([]*service.AuditLog(nil), repository.logs...) + repository.mu.Unlock() + require.Len(t, logs, 1) + require.Equal(t, "", logs[0].RequestBody) + require.NotContains(t, logs[0].RequestBody, "audit-canary") +} diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go index 9ca75ccab9..02b85682d7 100644 --- a/backend/internal/service/audit_log.go +++ b/backend/internal/service/audit_log.go @@ -129,8 +129,9 @@ var auditBodySensitiveExactKeys = func() map[string]struct{} { "key", // 字符串值内嵌完整凭证的字段: // proxy_key 为 protocol|host|port|username|password 拼接, - // custom_key 为用户自设的平台 API Key 明文。 - "proxy_key", "custom_key", + // custom_key 为用户自设的平台 API Key 明文, + // session 为 Ollama Cloud 用量的浏览器会话 Cookie 明文。 + "proxy_key", "custom_key", "session", } set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16) for _, k := range builtin { diff --git a/backend/internal/service/audit_log_test.go b/backend/internal/service/audit_log_test.go index ad4b4b8805..0045c936f2 100644 --- a/backend/internal/service/audit_log_test.go +++ b/backend/internal/service/audit_log_test.go @@ -62,6 +62,20 @@ func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) { } } +// 裸键 "session"(Ollama Cloud 会话保存的请求体字段)值整体就是浏览器 Cookie 明文, +// 必须命中键级脱敏;session_id 等运行态标识不受影响,保留以便追责。 +func TestRedactAuditBody_BareSessionKeyRedacted(t *testing.T) { + raw := []byte(`{"session": "wos-session=cookie-canary", "session_id": "sid-visible"}`) + out := RedactAuditBody(raw, "application/json") + + if strings.Contains(out, "cookie-canary") { + t.Fatalf("redacted body still contains the session cookie: %s", out) + } + if !strings.Contains(out, "sid-visible") { + t.Fatalf("session_id should be preserved for accountability: %s", out) + } +} + // TestRedactAuditBody_AuthoritativeTablesSynced 覆盖曾经漏网的凭证字段: // 账号 credentials 敏感子键、支付渠道无分隔符密钥、字符串值内嵌凭证的 proxy_key / custom_key, // 以及 camelCase 等命名变体(归一化比对)。