package store import ( "bytes" "context" "crypto/sha256" "database/sql" "encoding/json" "errors" "fmt" "yovision/bell/internal/audit" ) func (p *Postgres) AuditRelayReady(ctx context.Context) error { var version int64 if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 4 { return errors.New("postgres Bell schema migration v4 is required for audit relay") } var auditSelect, auditInsert, auditUpdate, auditDelete, auditTruncate bool var receiptUse bool if err := p.db.QueryRowContext(ctx, `SELECT has_table_privilege(current_user, 'bell.audit_events', 'SELECT'), has_table_privilege(current_user, 'bell.audit_events', 'INSERT'), has_table_privilege(current_user, 'bell.audit_events', 'UPDATE'), has_table_privilege(current_user, 'bell.audit_events', 'DELETE'), has_table_privilege(current_user, 'bell.audit_events', 'TRUNCATE'), has_table_privilege(current_user, 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')`).Scan( &auditSelect, &auditInsert, &auditUpdate, &auditDelete, &auditTruncate, &receiptUse, ); err != nil { return errors.New("verify Bell audit relay privileges") } if !auditSelect || !auditInsert || auditUpdate || auditDelete || auditTruncate || !receiptUse { return errors.New("Bell audit relay privileges violate append-only boundary") } return nil } func (p *Postgres) ProcessAuditBatch( ctx context.Context, keyID, nonce string, requestHash [sha256.Size]byte, candidates []audit.Candidate, ) ([]audit.Result, error) { if len(candidates) < 1 || len(candidates) > audit.MaxBatchSize { return nil, errors.New("invalid audit candidate batch") } tx, err := p.db.BeginTx(ctx, nil) if err != nil { return nil, errors.New("begin Bell audit batch") } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, keyID, nonce); err != nil { return nil, errors.New("lock Bell audit receipt") } if _, err := tx.ExecContext(ctx, `DELETE FROM bell.audit_relay_receipts WHERE expires_at <= clock_timestamp()`); err != nil { return nil, errors.New("expire Bell audit receipts") } var existingHash, existingBody []byte err = tx.QueryRowContext(ctx, `SELECT request_hash, response_body::text FROM bell.audit_relay_receipts WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&existingHash, &existingBody) if err == nil { if !bytes.Equal(existingHash, requestHash[:]) { return nil, audit.ErrReplayConflict } var response audit.BatchResponse if err := json.Unmarshal(existingBody, &response); err != nil { return nil, errors.New("decode stored Bell audit receipt") } if err := tx.Commit(); err != nil { return nil, errors.New("commit Bell audit replay") } return response.Results, nil } if !errors.Is(err, sql.ErrNoRows) { return nil, errors.New("read Bell audit receipt") } results := make([]audit.Result, 0, len(candidates)) for _, candidate := range candidates { if candidate.ErrorCode != "" { code := candidate.ErrorCode results = append(results, audit.Result{EventID: candidate.Envelope.Event.EventID, Status: "rejected", ErrorCode: &code}) continue } event := candidate.Envelope.Event payload, err := json.Marshal(event) if err != nil { return nil, errors.New("encode Bell audit fact") } result, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_events( source_system, event_id, schema_version, event_type, tenant_id, site_id, device_id, actor_type, actor_id, reason, trace_id, aggregate_generation, quota_source_version, area_policy_source_version, payload, occurred_at, record_hash ) VALUES ('sense',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) ON CONFLICT (source_system,event_id) DO NOTHING`, event.EventID, candidate.Envelope.SchemaVersion, event.EventType, event.TenantID, event.SiteID, event.DeviceID, event.Actor.Type, event.Actor.ID, event.Reason, event.TraceID, event.AggregateGeneration, event.ProjectionVersions.QuotaSourceVersion, event.ProjectionVersions.AreaPolicySourceVersion, payload, event.OccurredAt, candidate.RecordHash[:]) if err != nil { return nil, fmt.Errorf("insert Bell audit fact: %w", err) } affected, err := result.RowsAffected() if err != nil { return nil, errors.New("read Bell audit insert result") } if affected == 1 { results = append(results, audit.Result{EventID: event.EventID, Status: "accepted"}) continue } var storedHash []byte if err := tx.QueryRowContext(ctx, `SELECT record_hash FROM bell.audit_events WHERE source_system='sense' AND event_id=$1`, event.EventID).Scan(&storedHash); err != nil { return nil, errors.New("read existing Bell audit fact") } if bytes.Equal(storedHash, candidate.RecordHash[:]) { results = append(results, audit.Result{EventID: event.EventID, Status: "duplicate"}) } else { code := "id_conflict" results = append(results, audit.Result{EventID: event.EventID, Status: "rejected", ErrorCode: &code}) } } encoded, err := json.Marshal(audit.BatchResponse{Results: results}) if err != nil { return nil, errors.New("encode Bell audit response") } if _, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_relay_receipts( key_id, nonce, request_hash, response_status, response_body, expires_at ) VALUES ($1,$2,$3,200,$4::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], encoded); err != nil { return nil, errors.New("insert Bell audit receipt") } if err := tx.Commit(); err != nil { return nil, errors.New("commit Bell audit batch") } return results, nil }