prompt_repository.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. package securityaudit
  2. import (
  3. "context"
  4. "database/sql"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "strings"
  9. "time"
  10. )
  11. const (
  12. promptAuditAdmissionLockKey int64 = 579147893221901921
  13. promptAuditConfigLockKey int64 = 579147893221901922
  14. )
  15. var (
  16. ErrQueueFull = errors.New("prompt audit queue full")
  17. ErrQueueAdmissionBusy = errors.New("prompt audit queue admission busy")
  18. ErrLeaseLost = errors.New("prompt audit worker lease lost")
  19. ErrEventNotFound = errors.New("prompt audit event not found")
  20. )
  21. type Job struct {
  22. ID int64
  23. Snapshot PromptSnapshot
  24. ExecutionMode Mode
  25. ConfigVersion int64
  26. Status string
  27. Attempts int
  28. MaxAttempts int
  29. ClaimVersion int64
  30. NextAttemptAt time.Time
  31. ProcessingStartedAt *time.Time
  32. ProcessedAt *time.Time
  33. LastErrorCode string
  34. LastErrorMessage string
  35. CreatedAt time.Time
  36. UpdatedAt time.Time
  37. }
  38. type Event struct {
  39. ID int64 `json:"id"`
  40. JobID int64 `json:"job_id"`
  41. Snapshot PromptSnapshot `json:"snapshot"`
  42. Decision EventDecision `json:"decision"`
  43. RiskLevel RiskLevel `json:"risk_level"`
  44. Action Action `json:"action"`
  45. Categories []string `json:"categories"`
  46. MatchedScanners []string `json:"matched_scanners"`
  47. ScannerScores map[string]float64 `json:"scanner_scores"`
  48. ScannerEvidence map[string]string `json:"scanner_evidence"`
  49. ScannerBackend string `json:"scanner_backend"`
  50. ScannerVersion string `json:"scanner_version"`
  51. GuardEndpointID string `json:"guard_endpoint_id"`
  52. PolicyID string `json:"policy_id"`
  53. PolicyVersion int `json:"policy_version"`
  54. ConfigVersion int64 `json:"config_version"`
  55. ChunkTotal int `json:"chunk_total"`
  56. LatencyMS int `json:"latency_ms"`
  57. IssueSummaries []IssueSummary `json:"issue_summaries"`
  58. CreatedAt time.Time `json:"created_at"`
  59. }
  60. type JobRepository interface {
  61. CreateStagingWithCapacity(ctx context.Context, snapshot PromptSnapshot, configVersion int64, maxAttempts, capacity int) (*Job, error)
  62. PublishQueued(ctx context.Context, jobID int64) error
  63. MarkStagingFailed(ctx context.Context, jobID int64, code, message string) error
  64. ClaimNextJob(ctx context.Context, now time.Time) (*Job, bool, error)
  65. RefreshLease(ctx context.Context, jobID, claimVersion int64, now time.Time) error
  66. Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error)
  67. Retry(ctx context.Context, jobID, claimVersion int64, next time.Time, code, message string) error
  68. Fail(ctx context.Context, jobID, claimVersion int64, code, message string) error
  69. ReclaimStale(ctx context.Context, stagingBefore, processingBefore time.Time, limit int) (int64, error)
  70. QueueStats(ctx context.Context) (QueueStats, error)
  71. RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error)
  72. }
  73. type PostgreSQLRepository struct {
  74. db *sql.DB
  75. clock Clock
  76. }
  77. func NewPostgreSQLRepository(db *sql.DB) *PostgreSQLRepository {
  78. return &PostgreSQLRepository{db: db, clock: realClock{}}
  79. }
  80. func (r *PostgreSQLRepository) CreateStagingWithCapacity(ctx context.Context, snapshot PromptSnapshot, configVersion int64, maxAttempts, capacity int) (*Job, error) {
  81. if r == nil || r.db == nil {
  82. return nil, errors.New("prompt audit database unavailable")
  83. }
  84. tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
  85. if err != nil {
  86. return nil, err
  87. }
  88. defer func() { _ = tx.Rollback() }()
  89. var locked bool
  90. if err := tx.QueryRowContext(ctx, `SELECT pg_try_advisory_xact_lock($1)`, promptAuditAdmissionLockKey).Scan(&locked); err != nil {
  91. return nil, err
  92. }
  93. if !locked {
  94. return nil, ErrQueueAdmissionBusy
  95. }
  96. var active int
  97. if err := tx.QueryRowContext(ctx, `
  98. SELECT COUNT(*) FROM prompt_audit_jobs
  99. WHERE status IN ('staging','queued','processing','retry')`).Scan(&active); err != nil {
  100. return nil, err
  101. }
  102. if capacity <= 0 || active >= capacity {
  103. return nil, ErrQueueFull
  104. }
  105. if maxAttempts <= 0 {
  106. maxAttempts = 3
  107. }
  108. job, err := insertJob(ctx, tx, snapshot.Redacted(), ModeAsync, configVersion, "staging", maxAttempts)
  109. if err != nil {
  110. return nil, err
  111. }
  112. if err := tx.Commit(); err != nil {
  113. return nil, err
  114. }
  115. return job, nil
  116. }
  117. func (r *PostgreSQLRepository) PublishQueued(ctx context.Context, jobID int64) error {
  118. result, err := r.db.ExecContext(ctx, `
  119. UPDATE prompt_audit_jobs SET status='queued', next_attempt_at=NOW(), updated_at=NOW()
  120. WHERE id=$1 AND status='staging'`, jobID)
  121. return requireOneRow(result, err, ErrLeaseLost)
  122. }
  123. func (r *PostgreSQLRepository) MarkStagingFailed(ctx context.Context, jobID int64, code, _ string) error {
  124. code, message := sanitizeStoredError(code)
  125. result, err := r.db.ExecContext(ctx, `
  126. UPDATE prompt_audit_jobs
  127. SET status='failed', processed_at=NOW(), updated_at=NOW(), last_error_code=$2, last_error_message=$3
  128. WHERE id=$1 AND status='staging'`, jobID, code, message)
  129. return requireOneRow(result, err, ErrLeaseLost)
  130. }
  131. func (r *PostgreSQLRepository) ClaimNextJob(ctx context.Context, now time.Time) (*Job, bool, error) {
  132. row := r.db.QueryRowContext(ctx, `
  133. WITH candidate AS (
  134. SELECT id FROM prompt_audit_jobs
  135. WHERE status IN ('queued','retry') AND next_attempt_at <= $1
  136. ORDER BY next_attempt_at, id
  137. FOR UPDATE SKIP LOCKED
  138. LIMIT 1
  139. )
  140. UPDATE prompt_audit_jobs AS j
  141. SET status='processing', attempts=j.attempts+1, claim_version=j.claim_version+1,
  142. processing_started_at=$1, updated_at=$1
  143. FROM candidate
  144. WHERE j.id=candidate.id
  145. RETURNING `+jobColumns("j"), now.UTC())
  146. job, err := scanJob(row)
  147. if errors.Is(err, sql.ErrNoRows) {
  148. return nil, false, nil
  149. }
  150. return job, err == nil, err
  151. }
  152. func (r *PostgreSQLRepository) RefreshLease(ctx context.Context, jobID, claimVersion int64, now time.Time) error {
  153. result, err := r.db.ExecContext(ctx, `
  154. UPDATE prompt_audit_jobs SET processing_started_at=$3, updated_at=$3
  155. WHERE id=$1 AND status='processing' AND claim_version=$2`, jobID, claimVersion, now.UTC())
  156. return requireOneRow(result, err, ErrLeaseLost)
  157. }
  158. func (r *PostgreSQLRepository) Complete(ctx context.Context, job *Job, result *NormalizedResult, storePassEvents bool) (*Event, error) {
  159. if job == nil || result == nil {
  160. return nil, errors.New("prompt audit completion requires job and result")
  161. }
  162. tx, err := r.db.BeginTx(ctx, nil)
  163. if err != nil {
  164. return nil, err
  165. }
  166. defer func() { _ = tx.Rollback() }()
  167. updateResult, err := tx.ExecContext(ctx, `
  168. UPDATE prompt_audit_jobs SET status='done', processed_at=NOW(), updated_at=NOW(),
  169. last_error_code='', last_error_message=''
  170. WHERE id=$1 AND status='processing' AND claim_version=$2`, job.ID, job.ClaimVersion)
  171. if err := requireOneRow(updateResult, err, ErrLeaseLost); err != nil {
  172. return nil, err
  173. }
  174. var event *Event
  175. if shouldStorePromptAuditEvent(result.Decision, storePassEvents) {
  176. event, err = insertEvent(ctx, tx, job.ID, job.Snapshot.Redacted(), job.ConfigVersion, result)
  177. if err != nil {
  178. return nil, err
  179. }
  180. }
  181. if err := tx.Commit(); err != nil {
  182. return nil, err
  183. }
  184. return event, nil
  185. }
  186. func (r *PostgreSQLRepository) Retry(ctx context.Context, jobID, claimVersion int64, next time.Time, code, _ string) error {
  187. code, message := sanitizeStoredError(code)
  188. result, err := r.db.ExecContext(ctx, `
  189. UPDATE prompt_audit_jobs SET status='retry', next_attempt_at=$3, processing_started_at=NULL,
  190. updated_at=NOW(), last_error_code=$4, last_error_message=$5
  191. WHERE id=$1 AND status='processing' AND claim_version=$2`,
  192. jobID, claimVersion, next.UTC(), code, message)
  193. return requireOneRow(result, err, ErrLeaseLost)
  194. }
  195. func (r *PostgreSQLRepository) Fail(ctx context.Context, jobID, claimVersion int64, code, _ string) error {
  196. code, message := sanitizeStoredError(code)
  197. result, err := r.db.ExecContext(ctx, `
  198. UPDATE prompt_audit_jobs SET status='failed', processed_at=NOW(), processing_started_at=NULL,
  199. updated_at=NOW(), last_error_code=$3, last_error_message=$4
  200. WHERE id=$1 AND status='processing' AND claim_version=$2`,
  201. jobID, claimVersion, code, message)
  202. return requireOneRow(result, err, ErrLeaseLost)
  203. }
  204. func (r *PostgreSQLRepository) ReclaimStale(ctx context.Context, stagingBefore, processingBefore time.Time, limit int) (int64, error) {
  205. if limit <= 0 || limit > 1000 {
  206. limit = 100
  207. }
  208. result, err := r.db.ExecContext(ctx, `
  209. WITH stale AS (
  210. SELECT id FROM prompt_audit_jobs
  211. WHERE (status='staging' AND updated_at < $1)
  212. OR (status='processing' AND processing_started_at < $2)
  213. ORDER BY updated_at, id FOR UPDATE SKIP LOCKED LIMIT $3
  214. )
  215. UPDATE prompt_audit_jobs AS j
  216. SET status=CASE
  217. WHEN j.status='staging' THEN 'failed'
  218. WHEN j.attempts < j.max_attempts THEN 'retry'
  219. ELSE 'failed' END,
  220. next_attempt_at=CASE WHEN j.status='processing' AND j.attempts < j.max_attempts THEN NOW() ELSE j.next_attempt_at END,
  221. processing_started_at=NULL,
  222. processed_at=CASE WHEN j.status='staging' OR j.attempts >= j.max_attempts THEN NOW() ELSE NULL END,
  223. last_error_code=CASE WHEN j.status='staging' THEN 'staging_timeout' ELSE 'processing_lease_expired' END,
  224. last_error_message='', updated_at=NOW()
  225. FROM stale WHERE j.id=stale.id`, stagingBefore.UTC(), processingBefore.UTC(), limit)
  226. if err != nil {
  227. return 0, err
  228. }
  229. return result.RowsAffected()
  230. }
  231. func (r *PostgreSQLRepository) QueueStats(ctx context.Context) (QueueStats, error) {
  232. rows, err := r.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM prompt_audit_jobs GROUP BY status`)
  233. if err != nil {
  234. return QueueStats{}, err
  235. }
  236. defer func() { _ = rows.Close() }()
  237. var stats QueueStats
  238. for rows.Next() {
  239. var status string
  240. var count int64
  241. if err := rows.Scan(&status, &count); err != nil {
  242. return QueueStats{}, err
  243. }
  244. switch status {
  245. case "staging":
  246. stats.Staging = count
  247. case "queued":
  248. stats.Queued = count
  249. case "processing":
  250. stats.Processing = count
  251. case "retry":
  252. stats.Retry = count
  253. case "done":
  254. stats.Done = count
  255. case "failed":
  256. stats.Failed = count
  257. }
  258. }
  259. stats.Active = stats.Staging + stats.Queued + stats.Processing + stats.Retry
  260. return stats, rows.Err()
  261. }
  262. func (r *PostgreSQLRepository) RecordBlocking(ctx context.Context, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult, storePassEvents bool) (*Event, error) {
  263. if result == nil {
  264. return nil, errors.New("prompt guard result required")
  265. }
  266. tx, err := r.db.BeginTx(ctx, nil)
  267. if err != nil {
  268. return nil, err
  269. }
  270. defer func() { _ = tx.Rollback() }()
  271. job, err := insertJob(ctx, tx, snapshot.Redacted(), ModeBlocking, configVersion, "done", 1)
  272. if err != nil {
  273. return nil, err
  274. }
  275. var event *Event
  276. if shouldStorePromptAuditEvent(result.Decision, storePassEvents) {
  277. event, err = insertEvent(ctx, tx, job.ID, snapshot.Redacted(), configVersion, result)
  278. if err != nil {
  279. return nil, err
  280. }
  281. }
  282. if err := tx.Commit(); err != nil {
  283. return nil, err
  284. }
  285. return event, nil
  286. }
  287. // shouldStorePromptAuditEvent keeps store_pass_events scoped to safe results.
  288. // Risk events are always persisted while prompt auditing itself is enabled.
  289. func shouldStorePromptAuditEvent(decision EventDecision, storePassEvents bool) bool {
  290. return decision != EventPass || storePassEvents
  291. }
  292. type sqlQueryer interface {
  293. QueryRowContext(context.Context, string, ...any) *sql.Row
  294. }
  295. func insertJob(ctx context.Context, queryer sqlQueryer, snapshot PromptSnapshot, mode Mode, configVersion int64, status string, maxAttempts int) (*Job, error) {
  296. processedExpr := "NULL"
  297. if status == "done" || status == "failed" {
  298. processedExpr = "NOW()"
  299. }
  300. row := queryer.QueryRowContext(ctx, `
  301. INSERT INTO prompt_audit_jobs (
  302. request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot,
  303. group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,
  304. prompt_length,message_count,stage,execution_mode,config_version,status,max_attempts,processed_at
  305. ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,`+processedExpr+`)
  306. RETURNING `+jobColumns("prompt_audit_jobs"),
  307. snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot,
  308. nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName,
  309. snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash,
  310. snapshot.RedactedPreview, snapshot.PromptLength, snapshot.MessageCount, normalizeStage(snapshot.Stage),
  311. string(mode), configVersion, status, maxAttempts)
  312. return scanJob(row)
  313. }
  314. func insertEvent(ctx context.Context, queryer sqlQueryer, jobID int64, snapshot PromptSnapshot, configVersion int64, result *NormalizedResult) (*Event, error) {
  315. categories, _ := json.Marshal(result.Categories)
  316. matched, _ := json.Marshal(result.MatchedScanners)
  317. scores, _ := json.Marshal(result.ScannerScores)
  318. evidence := make(map[string]string, len(result.ScannerEvidence))
  319. for key, value := range result.ScannerEvidence {
  320. evidence[key] = RedactPreview(value, 160)
  321. }
  322. evidenceJSON, _ := json.Marshal(evidence)
  323. row := queryer.QueryRowContext(ctx, `
  324. INSERT INTO prompt_audit_events (
  325. job_id,request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot,
  326. group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,stage,
  327. decision,risk_level,action,categories,matched_scanners,scanner_scores,scanner_evidence,
  328. scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms,
  329. full_prompt
  330. ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,
  331. $20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31,$32)
  332. RETURNING `+eventDetailColumns("prompt_audit_events"),
  333. jobID, snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot,
  334. nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName,
  335. snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash,
  336. snapshot.RedactedPreview, normalizeStage(snapshot.Stage), string(result.Decision), string(result.RiskLevel),
  337. string(result.Action), categories, matched, scores, evidenceJSON, result.ScannerBackend, result.ScannerVersion,
  338. result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS,
  339. snapshot.FullPrompt)
  340. return scanEvent(row, true)
  341. }
  342. type rowScanner interface{ Scan(...any) error }
  343. func scanJob(row rowScanner) (*Job, error) {
  344. job := &Job{}
  345. var userID, apiKeyID, groupID sql.NullInt64
  346. var processingStarted, processed sql.NullTime
  347. err := row.Scan(
  348. &job.ID, &job.Snapshot.RequestID, &userID, &job.Snapshot.UsernameSnapshot, &job.Snapshot.UserEmailSnapshot,
  349. &apiKeyID, &job.Snapshot.APIKeyNameSnapshot, &groupID, &job.Snapshot.GroupName, &job.Snapshot.Provider,
  350. &job.Snapshot.Endpoint, &job.Snapshot.Protocol, &job.Snapshot.Model, &job.Snapshot.PromptHash,
  351. &job.Snapshot.RedactedPreview, &job.Snapshot.PromptLength, &job.Snapshot.MessageCount, &job.Snapshot.Stage,
  352. &job.ExecutionMode, &job.ConfigVersion, &job.Status, &job.Attempts, &job.MaxAttempts, &job.ClaimVersion,
  353. &job.NextAttemptAt, &processingStarted, &processed, &job.LastErrorCode, &job.LastErrorMessage,
  354. &job.CreatedAt, &job.UpdatedAt,
  355. )
  356. if err != nil {
  357. return nil, err
  358. }
  359. job.Snapshot.UserID = nullableInt64Value(userID)
  360. job.Snapshot.APIKeyID = nullableInt64Value(apiKeyID)
  361. job.Snapshot.GroupID = nullableInt64Ptr(groupID)
  362. if processingStarted.Valid {
  363. value := processingStarted.Time
  364. job.ProcessingStartedAt = &value
  365. }
  366. if processed.Valid {
  367. value := processed.Time
  368. job.ProcessedAt = &value
  369. }
  370. return job, nil
  371. }
  372. func jobColumns(alias string) string {
  373. return fmt.Sprintf(`%[1]s.id,%[1]s.request_id,%[1]s.user_id,%[1]s.username_snapshot,%[1]s.user_email_snapshot,
  374. %[1]s.api_key_id,%[1]s.api_key_name_snapshot,%[1]s.group_id,%[1]s.group_name,%[1]s.provider,
  375. %[1]s.endpoint,%[1]s.protocol,%[1]s.model,%[1]s.prompt_hash,%[1]s.redacted_preview,
  376. %[1]s.prompt_length,%[1]s.message_count,%[1]s.stage,%[1]s.execution_mode,%[1]s.config_version,%[1]s.status,
  377. %[1]s.attempts,%[1]s.max_attempts,%[1]s.claim_version,%[1]s.next_attempt_at,
  378. %[1]s.processing_started_at,%[1]s.processed_at,%[1]s.last_error_code,%[1]s.last_error_message,
  379. %[1]s.created_at,%[1]s.updated_at`, alias)
  380. }
  381. func normalizeStage(stage string) string {
  382. stage = strings.TrimSpace(stage)
  383. if stage == "" {
  384. return "http"
  385. }
  386. return stage
  387. }
  388. func requireOneRow(result sql.Result, err error, missing error) error {
  389. if err != nil {
  390. return err
  391. }
  392. rows, err := result.RowsAffected()
  393. if err != nil {
  394. return err
  395. }
  396. if rows != 1 {
  397. return missing
  398. }
  399. return nil
  400. }
  401. func nullableID(value int64) any {
  402. if value <= 0 {
  403. return nil
  404. }
  405. return value
  406. }
  407. func nullableInt64Value(value sql.NullInt64) int64 {
  408. if !value.Valid {
  409. return 0
  410. }
  411. return value.Int64
  412. }
  413. func nullableInt64Ptr(value sql.NullInt64) *int64 {
  414. if !value.Valid {
  415. return nil
  416. }
  417. result := value.Int64
  418. return &result
  419. }