mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 等命名变体(归一化比对)。
|
||||
|
||||
Reference in New Issue
Block a user