audit_log_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. package middleware
  2. import (
  3. "bytes"
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/service"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestDeriveAuditAction(t *testing.T) {
  15. cases := []struct {
  16. method string
  17. path string
  18. want string
  19. }{
  20. {"PUT", "/api/v1/admin/accounts/:id", "admin.accounts.update"},
  21. {"POST", "/api/v1/admin/accounts", "admin.accounts.create"},
  22. {"DELETE", "/api/v1/admin/backups/:id", "admin.backups.delete"},
  23. {"GET", "/api/v1/admin/users/:id/api-keys", "admin.users.api_keys.read"},
  24. {"POST", "/api/v1/admin/redeem-codes/batch", "admin.redeem_codes.batch.create"},
  25. }
  26. for _, tc := range cases {
  27. if got := deriveAuditAction(tc.method, tc.path); got != tc.want {
  28. t.Fatalf("deriveAuditAction(%q, %q) = %q, want %q", tc.method, tc.path, got, tc.want)
  29. }
  30. }
  31. }
  32. type auditCaptureRepository struct {
  33. mu sync.Mutex
  34. logs []*service.AuditLog
  35. }
  36. func (r *auditCaptureRepository) BatchInsert(_ context.Context, logs []*service.AuditLog) (int64, error) {
  37. r.mu.Lock()
  38. defer r.mu.Unlock()
  39. r.logs = append(r.logs, logs...)
  40. return int64(len(logs)), nil
  41. }
  42. func (r *auditCaptureRepository) Insert(_ context.Context, log *service.AuditLog) error {
  43. r.mu.Lock()
  44. defer r.mu.Unlock()
  45. r.logs = append(r.logs, log)
  46. return nil
  47. }
  48. func (r *auditCaptureRepository) List(context.Context, *service.AuditLogFilter) (*service.AuditLogList, error) {
  49. return &service.AuditLogList{}, nil
  50. }
  51. func (r *auditCaptureRepository) GetByID(context.Context, int64) (*service.AuditLog, error) {
  52. return nil, service.ErrAuditLogNotFound
  53. }
  54. func (r *auditCaptureRepository) Count(context.Context) (int64, error) { return 0, nil }
  55. func (r *auditCaptureRepository) TruncateAll(context.Context) error { return nil }
  56. func (r *auditCaptureRepository) DeleteBefore(context.Context, time.Time, int) (int64, error) {
  57. return 0, nil
  58. }
  59. func TestPromptAuditAdminOperationsUseOmittedBodiesAndAllowlistedDetails(t *testing.T) {
  60. gin.SetMode(gin.TestMode)
  61. repository := &auditCaptureRepository{}
  62. auditService := service.NewAuditLogService(repository, nil)
  63. auditService.Start()
  64. router := gin.New()
  65. router.Use(func(c *gin.Context) {
  66. c.Set(string(ContextKeyUser), AuthSubject{UserID: 77})
  67. c.Set(string(ContextKeyUserRole), "admin")
  68. c.Next()
  69. })
  70. router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService)))
  71. router.PUT("/api/v1/admin/prompt-audit/config", func(c *gin.Context) {
  72. SetAuditExtra(c, map[string]any{
  73. "result": "failed", "error_code": "prompt_audit_config_conflict", "config_version": int64(9),
  74. "token": "audit-canary-secret", "raw_prompt": "audit-canary-prompt", "nested": map[string]any{"unsafe": true},
  75. })
  76. c.JSON(http.StatusConflict, gin.H{"ok": false})
  77. })
  78. router.POST("/api/v1/admin/prompt-audit/endpoints/probe", func(c *gin.Context) {
  79. SetAuditExtra(c, map[string]any{
  80. "result": "success", "guard_endpoint_id": "guard-1", "http_status": 200,
  81. "latency_ms": 12, "token_applied": true,
  82. })
  83. c.JSON(http.StatusOK, gin.H{"ok": true})
  84. })
  85. for _, request := range []*http.Request{
  86. httptest.NewRequest(http.MethodPut, "/api/v1/admin/prompt-audit/config", bytes.NewBufferString(`{"expected_config_version":8,"token":"audit-canary-secret"}`)),
  87. httptest.NewRequest(http.MethodPost, "/api/v1/admin/prompt-audit/endpoints/probe", bytes.NewBufferString(`{"endpoint":{"token":"audit-canary-secret"}}`)),
  88. } {
  89. request.Header.Set("Content-Type", "application/json")
  90. recorder := httptest.NewRecorder()
  91. router.ServeHTTP(recorder, request)
  92. }
  93. auditService.Stop()
  94. repository.mu.Lock()
  95. logs := append([]*service.AuditLog(nil), repository.logs...)
  96. repository.mu.Unlock()
  97. require.Len(t, logs, 2)
  98. byAction := make(map[string]*service.AuditLog, len(logs))
  99. for _, entry := range logs {
  100. byAction[entry.Action] = entry
  101. require.Equal(t, "<credential-bearing body omitted>", entry.RequestBody)
  102. require.NotContains(t, entry.RequestBody, "audit-canary")
  103. require.NotContains(t, entry.Extra, "token")
  104. require.NotContains(t, entry.Extra, "raw_prompt")
  105. require.NotContains(t, entry.Extra, "nested")
  106. }
  107. config := byAction["admin.prompt_audit.config.update"]
  108. require.NotNil(t, config)
  109. require.Equal(t, http.StatusConflict, config.StatusCode)
  110. require.Equal(t, "failed", config.Extra["result"])
  111. require.Equal(t, "prompt_audit_config_conflict", config.Extra["error_code"])
  112. require.EqualValues(t, 9, config.Extra["config_version"])
  113. probe := byAction["admin.prompt_audit.endpoint.probe"]
  114. require.NotNil(t, probe)
  115. require.Equal(t, http.StatusOK, probe.StatusCode)
  116. require.Equal(t, "success", probe.Extra["result"])
  117. require.Equal(t, "guard-1", probe.Extra["guard_endpoint_id"])
  118. require.Equal(t, true, probe.Extra["token_applied"])
  119. }
  120. func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing.T) {
  121. expected := map[string]string{
  122. "PUT /api/v1/admin/prompt-audit/config": "admin.prompt_audit.config.update",
  123. "POST /api/v1/admin/prompt-audit/endpoints/probe": "admin.prompt_audit.endpoint.probe",
  124. "DELETE /api/v1/admin/prompt-audit/events/:id": "admin.prompt_audit.event.delete",
  125. "POST /api/v1/admin/prompt-audit/events/batch-delete": "admin.prompt_audit.events.batch_delete",
  126. "POST /api/v1/admin/prompt-audit/events/delete-preview": "admin.prompt_audit.events.delete_preview",
  127. "POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete",
  128. }
  129. for route, action := range expected {
  130. require.Equal(t, action, auditActionOverrides[route])
  131. _, omitted := auditBodyOmittedRoutes[route]
  132. require.Truef(t, omitted, "%s must not persist its credential or confirmation-bearing body", route)
  133. }
  134. }
  135. func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *testing.T) {
  136. route := "POST /api/v1/auth/passkey/login/finish"
  137. require.Equal(t, service.AuditActionLogin, auditActionOverrides[route])
  138. require.Contains(t, auditBodyOmittedRoutes, route)
  139. }
  140. // Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键
  141. // "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。
  142. func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) {
  143. gin.SetMode(gin.TestMode)
  144. require.Contains(t, auditBodyOmittedRoutes, "PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session")
  145. repository := &auditCaptureRepository{}
  146. auditService := service.NewAuditLogService(repository, nil)
  147. auditService.Start()
  148. router := gin.New()
  149. router.Use(func(c *gin.Context) {
  150. c.Set(string(ContextKeyUser), AuthSubject{UserID: 77})
  151. c.Set(string(ContextKeyUserRole), "admin")
  152. c.Next()
  153. })
  154. router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService)))
  155. router.PUT("/api/v1/admin/accounts/:id/ollama-cloud-usage/session", func(c *gin.Context) {
  156. c.JSON(http.StatusOK, gin.H{"ok": true})
  157. })
  158. request := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/7/ollama-cloud-usage/session",
  159. bytes.NewBufferString(`{"session":"wos-session=audit-canary-cookie; __Secure-authjs.session-token.0=audit-canary-shard"}`))
  160. request.Header.Set("Content-Type", "application/json")
  161. recorder := httptest.NewRecorder()
  162. router.ServeHTTP(recorder, request)
  163. require.Equal(t, http.StatusOK, recorder.Code)
  164. auditService.Stop()
  165. repository.mu.Lock()
  166. logs := append([]*service.AuditLog(nil), repository.logs...)
  167. repository.mu.Unlock()
  168. require.Len(t, logs, 1)
  169. require.Equal(t, "<credential-bearing body omitted>", logs[0].RequestBody)
  170. require.NotContains(t, logs[0].RequestBody, "audit-canary")
  171. }