prompt_event_repository.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. package securityaudit
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "database/sql"
  6. "encoding/hex"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "strings"
  11. "time"
  12. "github.com/lib/pq"
  13. )
  14. type EventFilter struct {
  15. Decision string `json:"decision,omitempty"`
  16. RiskLevel string `json:"risk_level,omitempty"`
  17. Endpoint string `json:"endpoint,omitempty"`
  18. GroupID *int64 `json:"group_id,omitempty"`
  19. UserID *int64 `json:"user_id,omitempty"`
  20. APIKeyID *int64 `json:"api_key_id,omitempty"`
  21. RequestID string `json:"request_id,omitempty"`
  22. PromptHash string `json:"prompt_hash,omitempty"`
  23. Keyword string `json:"keyword,omitempty"`
  24. StartAt *time.Time `json:"start_at,omitempty"`
  25. EndAt *time.Time `json:"end_at,omitempty"`
  26. }
  27. type EventPage struct {
  28. Items []*Event `json:"items"`
  29. Total int64 `json:"total"`
  30. Page int `json:"page"`
  31. PageSize int `json:"page_size"`
  32. Pages int `json:"pages"`
  33. }
  34. type DeletePreview struct {
  35. MatchedCount int64 `json:"matched_count"`
  36. FilterSummary EventFilter `json:"filter_summary"`
  37. SnapshotMaxID int64 `json:"snapshot_max_id"`
  38. FilterHash string `json:"filter_hash"`
  39. ConfirmationToken string `json:"confirmation_token,omitempty"`
  40. ExpiresAt time.Time `json:"expires_at,omitempty"`
  41. }
  42. type DeleteResult struct {
  43. DeletedEvents int64 `json:"deleted_events"`
  44. DeletedJobs int64 `json:"deleted_jobs"`
  45. JobIDs []int64 `json:"-"`
  46. }
  47. type EventRepository interface {
  48. ListEvents(ctx context.Context, filter EventFilter, page, pageSize int) (*EventPage, error)
  49. GetEvent(ctx context.Context, id int64) (*Event, error)
  50. DeleteEvent(ctx context.Context, id int64) (*DeleteResult, error)
  51. DeleteEventsByIDs(ctx context.Context, ids []int64) (*DeleteResult, error)
  52. PreviewDelete(ctx context.Context, filter EventFilter) (*DeletePreview, error)
  53. DeleteEventsByFilter(ctx context.Context, filter EventFilter, snapshotMaxID int64, batchSize int) (*DeleteResult, error)
  54. }
  55. func (r *PostgreSQLRepository) ListEvents(ctx context.Context, filter EventFilter, page, pageSize int) (*EventPage, error) {
  56. if page < 1 {
  57. page = 1
  58. }
  59. if pageSize < 1 {
  60. pageSize = 20
  61. }
  62. if pageSize > 100 {
  63. pageSize = 100
  64. }
  65. where, args := buildEventWhere(filter, 1)
  66. var total int64
  67. if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM prompt_audit_events e`+where, args...).Scan(&total); err != nil {
  68. return nil, err
  69. }
  70. queryArgs := append([]any(nil), args...)
  71. limitIndex := len(queryArgs) + 1
  72. queryArgs = append(queryArgs, pageSize, (page-1)*pageSize)
  73. rows, err := r.db.QueryContext(ctx, `SELECT `+eventColumns("e")+` FROM prompt_audit_events e`+where+
  74. fmt.Sprintf(` ORDER BY e.created_at DESC, e.id DESC LIMIT $%d OFFSET $%d`, limitIndex, limitIndex+1), queryArgs...)
  75. if err != nil {
  76. return nil, err
  77. }
  78. defer func() { _ = rows.Close() }()
  79. items := make([]*Event, 0, pageSize)
  80. for rows.Next() {
  81. event, err := scanEvent(rows)
  82. if err != nil {
  83. return nil, err
  84. }
  85. items = append(items, event)
  86. }
  87. if err := rows.Err(); err != nil {
  88. return nil, err
  89. }
  90. pages := 0
  91. if total > 0 {
  92. pages = int((total + int64(pageSize) - 1) / int64(pageSize))
  93. }
  94. return &EventPage{Items: items, Total: total, Page: page, PageSize: pageSize, Pages: pages}, nil
  95. }
  96. func (r *PostgreSQLRepository) GetEvent(ctx context.Context, id int64) (*Event, error) {
  97. event, err := scanEvent(r.db.QueryRowContext(ctx, `SELECT `+eventDetailColumns("e")+` FROM prompt_audit_events e WHERE e.id=$1`, id), true)
  98. if errors.Is(err, sql.ErrNoRows) {
  99. return nil, ErrEventNotFound
  100. }
  101. return event, err
  102. }
  103. func (r *PostgreSQLRepository) DeleteEvent(ctx context.Context, id int64) (*DeleteResult, error) {
  104. return r.DeleteEventsByIDs(ctx, []int64{id})
  105. }
  106. func (r *PostgreSQLRepository) DeleteEventsByIDs(ctx context.Context, ids []int64) (*DeleteResult, error) {
  107. ids = canonicalInt64s(ids)
  108. if len(ids) == 0 {
  109. return &DeleteResult{}, nil
  110. }
  111. if len(ids) > 500 {
  112. return nil, errors.New("prompt audit delete batch exceeds 500 events")
  113. }
  114. tx, err := r.db.BeginTx(ctx, nil)
  115. if err != nil {
  116. return nil, err
  117. }
  118. defer func() { _ = tx.Rollback() }()
  119. rows, err := tx.QueryContext(ctx, `DELETE FROM prompt_audit_events WHERE id=ANY($1) RETURNING job_id`, pq.Array(ids))
  120. if err != nil {
  121. return nil, err
  122. }
  123. jobIDs, err := scanReturnedJobIDs(rows)
  124. if err != nil {
  125. return nil, err
  126. }
  127. deletedJobs, err := deleteOrphanJobs(ctx, tx, jobIDs)
  128. if err != nil {
  129. return nil, err
  130. }
  131. if err := tx.Commit(); err != nil {
  132. return nil, err
  133. }
  134. return &DeleteResult{DeletedEvents: int64(len(jobIDs)), DeletedJobs: deletedJobs, JobIDs: canonicalInt64s(jobIDs)}, nil
  135. }
  136. func (r *PostgreSQLRepository) PreviewDelete(ctx context.Context, filter EventFilter) (*DeletePreview, error) {
  137. if err := validateDeleteFilter(filter); err != nil {
  138. return nil, err
  139. }
  140. tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
  141. if err != nil {
  142. return nil, err
  143. }
  144. defer func() { _ = tx.Rollback() }()
  145. where, args := buildEventWhere(filter, 1)
  146. var count, maxID int64
  147. if err := tx.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(MAX(e.id),0) FROM prompt_audit_events e`+where, args...).Scan(&count, &maxID); err != nil {
  148. return nil, err
  149. }
  150. if err := tx.Commit(); err != nil {
  151. return nil, err
  152. }
  153. canonical := canonicalEventFilter(filter)
  154. return &DeletePreview{MatchedCount: count, FilterSummary: canonical, SnapshotMaxID: maxID, FilterHash: FilterHash(canonical, maxID)}, nil
  155. }
  156. func (r *PostgreSQLRepository) DeleteEventsByFilter(ctx context.Context, filter EventFilter, snapshotMaxID int64, batchSize int) (*DeleteResult, error) {
  157. if err := validateDeleteFilter(filter); err != nil {
  158. return nil, err
  159. }
  160. if snapshotMaxID <= 0 {
  161. return &DeleteResult{}, nil
  162. }
  163. if batchSize < 1 || batchSize > 1000 {
  164. batchSize = 200
  165. }
  166. total := &DeleteResult{}
  167. jobSet := map[int64]struct{}{}
  168. for {
  169. tx, err := r.db.BeginTx(ctx, nil)
  170. if err != nil {
  171. return nil, err
  172. }
  173. where, args := buildEventWhere(filter, 1)
  174. maxIndex := len(args) + 1
  175. limitIndex := maxIndex + 1
  176. args = append(args, snapshotMaxID, batchSize)
  177. rows, err := tx.QueryContext(ctx, `
  178. WITH selected AS (
  179. SELECT e.id FROM prompt_audit_events e`+where+
  180. fmt.Sprintf(` AND e.id <= $%d ORDER BY e.id LIMIT $%d FOR UPDATE SKIP LOCKED`, maxIndex, limitIndex)+`
  181. ), deleted AS (
  182. DELETE FROM prompt_audit_events e USING selected s WHERE e.id=s.id RETURNING e.job_id
  183. ) SELECT job_id FROM deleted`, args...)
  184. if err != nil {
  185. _ = tx.Rollback()
  186. return nil, err
  187. }
  188. jobIDs, err := scanReturnedJobIDs(rows)
  189. if err != nil {
  190. _ = tx.Rollback()
  191. return nil, err
  192. }
  193. deletedJobs, err := deleteOrphanJobs(ctx, tx, jobIDs)
  194. if err != nil {
  195. _ = tx.Rollback()
  196. return nil, err
  197. }
  198. if err := tx.Commit(); err != nil {
  199. return nil, err
  200. }
  201. total.DeletedEvents += int64(len(jobIDs))
  202. total.DeletedJobs += deletedJobs
  203. for _, id := range jobIDs {
  204. jobSet[id] = struct{}{}
  205. }
  206. if len(jobIDs) < batchSize {
  207. break
  208. }
  209. }
  210. for id := range jobSet {
  211. total.JobIDs = append(total.JobIDs, id)
  212. }
  213. total.JobIDs = canonicalInt64s(total.JobIDs)
  214. return total, nil
  215. }
  216. func FilterHash(filter EventFilter, snapshotMaxID int64) string {
  217. payload := struct {
  218. Filter EventFilter `json:"filter"`
  219. SnapshotMaxID int64 `json:"snapshot_max_id"`
  220. }{canonicalEventFilter(filter), snapshotMaxID}
  221. raw, _ := json.Marshal(payload)
  222. digest := sha256.Sum256(raw)
  223. return hex.EncodeToString(digest[:])
  224. }
  225. func validateDeleteFilter(filter EventFilter) error {
  226. if filter.StartAt == nil || filter.EndAt == nil || !filter.StartAt.Before(*filter.EndAt) {
  227. return errors.New("prompt audit filter delete requires a valid explicit time range")
  228. }
  229. return nil
  230. }
  231. func canonicalEventFilter(filter EventFilter) EventFilter {
  232. filter.Decision = strings.TrimSpace(strings.ToLower(filter.Decision))
  233. filter.RiskLevel = strings.TrimSpace(strings.ToLower(filter.RiskLevel))
  234. filter.Endpoint = strings.TrimSpace(filter.Endpoint)
  235. filter.RequestID = strings.TrimSpace(filter.RequestID)
  236. filter.PromptHash = strings.ToLower(strings.TrimSpace(filter.PromptHash))
  237. filter.Keyword = strings.TrimSpace(filter.Keyword)
  238. if filter.StartAt != nil {
  239. value := filter.StartAt.UTC()
  240. filter.StartAt = &value
  241. }
  242. if filter.EndAt != nil {
  243. value := filter.EndAt.UTC()
  244. filter.EndAt = &value
  245. }
  246. return filter
  247. }
  248. func buildEventWhere(filter EventFilter, firstIndex int) (string, []any) {
  249. filter = canonicalEventFilter(filter)
  250. clauses := []string{" WHERE TRUE"}
  251. args := make([]any, 0, 12)
  252. add := func(clause string, value any) {
  253. clauses = append(clauses, fmt.Sprintf(clause, firstIndex+len(args)))
  254. args = append(args, value)
  255. }
  256. if filter.Decision != "" {
  257. add(" AND e.decision=$%d", filter.Decision)
  258. }
  259. if filter.RiskLevel != "" {
  260. add(" AND e.risk_level=$%d", filter.RiskLevel)
  261. }
  262. if filter.Endpoint != "" {
  263. add(" AND e.endpoint=$%d", filter.Endpoint)
  264. }
  265. if filter.GroupID != nil {
  266. add(" AND e.group_id=$%d", *filter.GroupID)
  267. }
  268. if filter.UserID != nil {
  269. add(" AND e.user_id=$%d", *filter.UserID)
  270. }
  271. if filter.APIKeyID != nil {
  272. add(" AND e.api_key_id=$%d", *filter.APIKeyID)
  273. }
  274. if filter.RequestID != "" {
  275. add(" AND e.request_id=$%d", filter.RequestID)
  276. }
  277. if filter.PromptHash != "" {
  278. add(" AND e.prompt_hash=$%d", filter.PromptHash)
  279. }
  280. if filter.Keyword != "" {
  281. add(` AND (e.request_id ILIKE $%d OR e.prompt_hash ILIKE $%d OR e.redacted_preview ILIKE $%d
  282. OR e.username_snapshot ILIKE $%d OR e.user_email_snapshot ILIKE $%d OR e.api_key_name_snapshot ILIKE $%d)`, "%"+TrimRunes(filter.Keyword, 128)+"%")
  283. // The clause has six placeholders but add only supplied one. Rebuild it with one shared placeholder.
  284. clauses[len(clauses)-1] = fmt.Sprintf(` AND (e.request_id ILIKE $%[1]d OR e.prompt_hash ILIKE $%[1]d OR e.redacted_preview ILIKE $%[1]d
  285. OR e.username_snapshot ILIKE $%[1]d OR e.user_email_snapshot ILIKE $%[1]d OR e.api_key_name_snapshot ILIKE $%[1]d)`, firstIndex+len(args)-1)
  286. }
  287. if filter.StartAt != nil {
  288. add(" AND e.created_at >= $%d", filter.StartAt.UTC())
  289. }
  290. if filter.EndAt != nil {
  291. add(" AND e.created_at <= $%d", filter.EndAt.UTC())
  292. }
  293. return strings.Join(clauses, ""), args
  294. }
  295. func eventColumns(alias string) string {
  296. return fmt.Sprintf(`%[1]s.id,%[1]s.job_id,%[1]s.request_id,%[1]s.user_id,%[1]s.username_snapshot,
  297. %[1]s.user_email_snapshot,%[1]s.api_key_id,%[1]s.api_key_name_snapshot,%[1]s.group_id,%[1]s.group_name,
  298. %[1]s.provider,%[1]s.endpoint,%[1]s.protocol,%[1]s.model,%[1]s.prompt_hash,%[1]s.redacted_preview,
  299. %[1]s.stage,%[1]s.decision,%[1]s.risk_level,%[1]s.action,%[1]s.categories,%[1]s.matched_scanners,
  300. %[1]s.scanner_scores,%[1]s.scanner_evidence,%[1]s.scanner_backend,%[1]s.scanner_version,
  301. %[1]s.guard_endpoint_id,%[1]s.policy_id,%[1]s.policy_version,%[1]s.config_version,
  302. %[1]s.chunk_total,%[1]s.latency_ms,%[1]s.created_at`, alias)
  303. }
  304. // eventDetailColumns adds the full prompt, which can be large, so it is only
  305. // loaded for single-event detail reads and never for list pages.
  306. func eventDetailColumns(alias string) string {
  307. return eventColumns(alias) + fmt.Sprintf(",%[1]s.full_prompt", alias)
  308. }
  309. func scanEvent(row rowScanner, withFullPrompt ...bool) (*Event, error) {
  310. event := &Event{}
  311. var userID, apiKeyID, groupID sql.NullInt64
  312. var categories, matched, scores, evidence []byte
  313. dest := []any{&event.ID, &event.JobID, &event.Snapshot.RequestID, &userID,
  314. &event.Snapshot.UsernameSnapshot, &event.Snapshot.UserEmailSnapshot, &apiKeyID,
  315. &event.Snapshot.APIKeyNameSnapshot, &groupID, &event.Snapshot.GroupName,
  316. &event.Snapshot.Provider, &event.Snapshot.Endpoint, &event.Snapshot.Protocol, &event.Snapshot.Model,
  317. &event.Snapshot.PromptHash, &event.Snapshot.RedactedPreview, &event.Snapshot.Stage, &event.Decision,
  318. &event.RiskLevel, &event.Action, &categories, &matched, &scores, &evidence, &event.ScannerBackend,
  319. &event.ScannerVersion, &event.GuardEndpointID, &event.PolicyID, &event.PolicyVersion,
  320. &event.ConfigVersion, &event.ChunkTotal, &event.LatencyMS, &event.CreatedAt}
  321. if len(withFullPrompt) > 0 && withFullPrompt[0] {
  322. dest = append(dest, &event.Snapshot.FullPrompt)
  323. }
  324. err := row.Scan(dest...)
  325. if err != nil {
  326. return nil, err
  327. }
  328. event.Snapshot.UserID = nullableInt64Value(userID)
  329. event.Snapshot.APIKeyID = nullableInt64Value(apiKeyID)
  330. event.Snapshot.GroupID = nullableInt64Ptr(groupID)
  331. _ = json.Unmarshal(categories, &event.Categories)
  332. _ = json.Unmarshal(matched, &event.MatchedScanners)
  333. _ = json.Unmarshal(scores, &event.ScannerScores)
  334. _ = json.Unmarshal(evidence, &event.ScannerEvidence)
  335. result := NormalizedResult{Decision: event.Decision, RiskLevel: event.RiskLevel, Action: event.Action,
  336. Categories: event.Categories, MatchedScanners: event.MatchedScanners, ScannerScores: event.ScannerScores,
  337. ScannerEvidence: event.ScannerEvidence}
  338. event.IssueSummaries = BuildIssueSummaries(result)
  339. return event, nil
  340. }
  341. func scanReturnedJobIDs(rows *sql.Rows) ([]int64, error) {
  342. defer func() { _ = rows.Close() }()
  343. result := make([]int64, 0)
  344. for rows.Next() {
  345. var id int64
  346. if err := rows.Scan(&id); err != nil {
  347. return nil, err
  348. }
  349. result = append(result, id)
  350. }
  351. return result, rows.Err()
  352. }
  353. func deleteOrphanJobs(ctx context.Context, tx *sql.Tx, jobIDs []int64) (int64, error) {
  354. jobIDs = canonicalInt64s(jobIDs)
  355. if len(jobIDs) == 0 {
  356. return 0, nil
  357. }
  358. result, err := tx.ExecContext(ctx, `DELETE FROM prompt_audit_jobs j
  359. WHERE j.id=ANY($1) AND j.status <> 'processing'
  360. AND NOT EXISTS (SELECT 1 FROM prompt_audit_events e WHERE e.job_id=j.id)`, pq.Array(jobIDs))
  361. if err != nil {
  362. return 0, err
  363. }
  364. return result.RowsAffected()
  365. }