package storage import ( "context" "database/sql" "errors" "fmt" "net/netip" "slices" "strings" "time" ) const ( AuditPageSize = 50 maxAuditPageSize = 200 ) type AuditCursor struct { OccurredAt time.Time ID int64 } type AuditPageFilter struct { Actor string Action string Subject string Address netip.Addr After *time.Time Before *time.Time Older *AuditCursor Newer *AuditCursor Limit int } type AuditPage struct { Events []AuditEvent Older *AuditCursor Newer *AuditCursor } func auditPageQuery(filter AuditPageFilter, limit int) (string, []any) { query := strings.Builder{} query.WriteString(`SELECT id, actor, action, subject, occurred_at, detail, address FROM audit_log WHERE 1 = 1`) var args []any action := `action = ?` if filter.Actor != "" { action = `+action = ?` } for _, eq := range []struct{ term, value string }{ {`actor = ?`, filter.Actor}, {action, filter.Action}, {`subject = ?`, filter.Subject}, } { if eq.value != "" { query.WriteString(` AND ` + eq.term) args = append(args, eq.value) } } if filter.Address.IsValid() { query.WriteString(` AND address = ?`) args = append(args, filter.Address.Unmap().String()) } if filter.After != nil { query.WriteString(` AND occurred_at >= ?`) args = append(args, timeToInt(*filter.After)) } if filter.Before != nil { query.WriteString(` AND occurred_at < ?`) args = append(args, timeToInt(*filter.Before)) } order := `DESC` switch { case filter.Older != nil: query.WriteString(` AND (occurred_at, id) < (?, ?)`) args = append(args, timeToInt(filter.Older.OccurredAt), filter.Older.ID) case filter.Newer != nil: query.WriteString(` AND (occurred_at, id) > (?, ?)`) args = append(args, timeToInt(filter.Newer.OccurredAt), filter.Newer.ID) order = `ASC` } query.WriteString(` ORDER BY occurred_at ` + order + `, id ` + order + ` LIMIT ?`) args = append(args, limit+1) return query.String(), args } func (s *Store) ListAuditPage(ctx context.Context, filter AuditPageFilter) (AuditPage, error) { const operation = "list audit page" if err := s.checkOpen(operation); err != nil { return AuditPage{}, err } limit := filter.Limit if limit == 0 { limit = AuditPageSize } if limit < 1 || limit > maxAuditPageSize { return AuditPage{}, invalid(operation, fmt.Errorf("limit must be between 1 and %d", maxAuditPageSize)) } if filter.Older != nil && filter.Newer != nil { return AuditPage{}, invalid(operation, errors.New("a page is older or newer than one cursor, not both")) } if filter.Address.Zone() != "" { return AuditPage{}, invalid(operation, errors.New("an address filter has no zone")) } query, args := auditPageQuery(filter, limit) rows, err := s.sql.QueryContext(ctx, query, args...) if err != nil { return AuditPage{}, classify(operation, err) } defer rows.Close() var events []AuditEvent for rows.Next() { var e AuditEvent var occurredAt int64 var address sql.NullString if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Subject, &occurredAt, &e.Detail, &address); err != nil { return AuditPage{}, classify(operation, err) } e.OccurredAt = intToTime(occurredAt) e.Address = scanAddress(address) events = append(events, e) } if err := rows.Err(); err != nil { return AuditPage{}, classify(operation, err) } more := len(events) > limit events = events[:min(len(events), limit)] backward := filter.Newer != nil if backward { slices.Reverse(events) } page := AuditPage{Events: events} if len(events) == 0 { return page, nil } first, last := auditCursorOf(events[0]), auditCursorOf(events[len(events)-1]) if backward { page.Older = &last if more { page.Newer = &first } return page, nil } if more { page.Older = &last } if filter.Older != nil { page.Newer = &first } return page, nil } func auditCursorOf(e AuditEvent) AuditCursor { return AuditCursor{OccurredAt: e.OccurredAt, ID: e.ID} }