fix(ollama): 审计日志不落会话明文并收紧凭证清理守卫

- PUT /admin/accounts/:id/ollama-cloud-usage/session 加入审计整体不入库
  路由,并把裸键 session 纳入键级脱敏兜底,防止浏览器会话 Cookie 明文
  留存 audit_logs.request_body
- UpdateCredentials 的 Ollama 清理分支加顶层 credentials DISTINCT 守卫,
  凭证未变化的持久化不再误清 openai 探测快照或重写 NULL extra
This commit is contained in:
shaw
2026-07-23 16:43:18 +08:00
parent 3f03c93bc6
commit 2faa0891e4
7 changed files with 140 additions and 9 deletions
@@ -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 (
@@ -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")
}
@@ -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())
}
@@ -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 创建审计中间件。
@@ -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, "<credential-bearing body omitted>", logs[0].RequestBody)
require.NotContains(t, logs[0].RequestBody, "audit-canary")
}
+3 -2
View File
@@ -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 {
@@ -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 等命名变体(归一化比对)。