Files
silo-server/internal/activitylog/repo.go
T

277 lines
8.3 KiB
Go

package activitylog
import (
"context"
"encoding/base64"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// UserIPEntry is a row in the per-user IP history view.
type UserIPEntry struct {
ClientIP string `json:"client_ip"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
RequestCount int `json:"request_count"`
}
// IPUserEntry is a row in the per-IP user view.
type IPUserEntry struct {
UserID int `json:"user_id"`
Username string `json:"username"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
RequestCount int `json:"request_count"`
}
type AuditEntry struct {
ID int64 `json:"id"`
Timestamp time.Time `json:"timestamp"`
ClientIP string `json:"client_ip"`
UserID *int `json:"user_id,omitempty"`
ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
PlaybackSessionID string `json:"playback_session_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
NodeID string `json:"node_id,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
PathPattern string `json:"path_pattern,omitempty"`
StatusCode int `json:"status_code"`
UserAgent string `json:"user_agent,omitempty"`
DurationMs int `json:"duration_ms"`
}
type ListOptions struct {
From *time.Time
To *time.Time
Method string
StatusCode *int
PathPrefix string
ClientIP string
RequestID string
UserID *int
SessionID string
PlaybackSessionID string
Limit int
Cursor string
}
type ListResult struct {
Entries []AuditEntry `json:"entries"`
NextCursor string `json:"next_cursor,omitempty"`
}
// Repo provides query access to the activity_log table.
type Repo struct {
pool *pgxpool.Pool
}
// NewRepo creates a new activity log repository.
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
// UserIPs returns the IP addresses used by a given user within the lookback window.
func (r *Repo) UserIPs(ctx context.Context, userID int, days int) ([]UserIPEntry, error) {
rows, err := r.pool.Query(ctx, `
SELECT client_ip::text, min(timestamp) AS first_seen, max(timestamp) AS last_seen, count(*) AS request_count
FROM activity_log
WHERE user_id = $1 AND timestamp > now() - make_interval(days => $2)
GROUP BY client_ip
ORDER BY last_seen DESC
`, userID, days)
if err != nil {
return nil, fmt.Errorf("query user IPs: %w", err)
}
defer rows.Close()
var results []UserIPEntry
for rows.Next() {
var e UserIPEntry
if err := rows.Scan(&e.ClientIP, &e.FirstSeen, &e.LastSeen, &e.RequestCount); err != nil {
return nil, fmt.Errorf("scan user IP row: %w", err)
}
results = append(results, e)
}
return results, rows.Err()
}
// IPUsers returns the users that have connected from a given IP within the lookback window.
func (r *Repo) IPUsers(ctx context.Context, ip string, days int) ([]IPUserEntry, error) {
rows, err := r.pool.Query(ctx, `
SELECT a.user_id, COALESCE(u.username, ''), min(a.timestamp) AS first_seen,
max(a.timestamp) AS last_seen, count(*) AS request_count
FROM activity_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.client_ip = $1::inet AND a.timestamp > now() - make_interval(days => $2) AND a.user_id IS NOT NULL
GROUP BY a.user_id, u.username
ORDER BY last_seen DESC
`, ip, days)
if err != nil {
return nil, fmt.Errorf("query IP users: %w", err)
}
defer rows.Close()
var results []IPUserEntry
for rows.Next() {
var e IPUserEntry
if err := rows.Scan(&e.UserID, &e.Username, &e.FirstSeen, &e.LastSeen, &e.RequestCount); err != nil {
return nil, fmt.Errorf("scan IP user row: %w", err)
}
results = append(results, e)
}
return results, rows.Err()
}
func (r *Repo) List(ctx context.Context, opts ListOptions) (ListResult, error) {
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conditions := []string{"1=1"}
args := make([]any, 0, 12)
argIdx := 1
if opts.From != nil {
conditions = append(conditions, fmt.Sprintf("timestamp >= $%d", argIdx))
args = append(args, *opts.From)
argIdx++
}
if opts.To != nil {
conditions = append(conditions, fmt.Sprintf("timestamp <= $%d", argIdx))
args = append(args, *opts.To)
argIdx++
}
if opts.Method != "" {
conditions = append(conditions, fmt.Sprintf("method = $%d", argIdx))
args = append(args, strings.ToUpper(opts.Method))
argIdx++
}
if opts.StatusCode != nil {
conditions = append(conditions, fmt.Sprintf("status_code = $%d", argIdx))
args = append(args, *opts.StatusCode)
argIdx++
}
if opts.PathPrefix != "" {
conditions = append(conditions, fmt.Sprintf("path LIKE $%d", argIdx))
args = append(args, opts.PathPrefix+"%")
argIdx++
}
if opts.ClientIP != "" {
conditions = append(conditions, fmt.Sprintf("client_ip = $%d::inet", argIdx))
args = append(args, opts.ClientIP)
argIdx++
}
if opts.RequestID != "" {
conditions = append(conditions, fmt.Sprintf("request_id = $%d", argIdx))
args = append(args, opts.RequestID)
argIdx++
}
if opts.UserID != nil {
conditions = append(conditions, fmt.Sprintf("user_id = $%d", argIdx))
args = append(args, *opts.UserID)
argIdx++
}
if opts.SessionID != "" {
conditions = append(conditions, fmt.Sprintf("session_id = $%d", argIdx))
args = append(args, opts.SessionID)
argIdx++
}
if opts.PlaybackSessionID != "" {
conditions = append(conditions, fmt.Sprintf("playback_session_id = $%d", argIdx))
args = append(args, opts.PlaybackSessionID)
argIdx++
}
if opts.Cursor != "" {
cursorTs, cursorID, err := decodeCursor(opts.Cursor)
if err != nil {
return ListResult{}, err
}
conditions = append(conditions, fmt.Sprintf("(timestamp, id) < ($%d, $%d)", argIdx, argIdx+1))
args = append(args, cursorTs, cursorID)
argIdx += 2
}
query := fmt.Sprintf(`
SELECT id, timestamp, client_ip::text, user_id, impersonator_user_id, COALESCE(session_id, ''), COALESCE(playback_session_id, ''), COALESCE(request_id, ''), COALESCE(node_id, ''),
method, path, COALESCE(path_pattern, ''), COALESCE(status_code, 0), COALESCE(user_agent, ''), COALESCE(duration_ms, 0)
FROM activity_log
WHERE %s
ORDER BY timestamp DESC, id DESC
LIMIT $%d
`, strings.Join(conditions, " AND "), argIdx)
args = append(args, limit+1)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return ListResult{}, fmt.Errorf("list activity logs: %w", err)
}
defer rows.Close()
entries := make([]AuditEntry, 0, limit+1)
for rows.Next() {
var entry AuditEntry
if err := rows.Scan(
&entry.ID,
&entry.Timestamp,
&entry.ClientIP,
&entry.UserID,
&entry.ImpersonatorUserID,
&entry.SessionID,
&entry.PlaybackSessionID,
&entry.RequestID,
&entry.NodeID,
&entry.Method,
&entry.Path,
&entry.PathPattern,
&entry.StatusCode,
&entry.UserAgent,
&entry.DurationMs,
); err != nil {
return ListResult{}, fmt.Errorf("scan activity log row: %w", err)
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return ListResult{}, fmt.Errorf("iterate activity logs: %w", err)
}
result := ListResult{}
if len(entries) > limit {
last := entries[limit-1]
result.NextCursor = encodeCursor(last.Timestamp, last.ID)
entries = entries[:limit]
}
result.Entries = entries
return result, nil
}
func encodeCursor(ts time.Time, id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d|%d", ts.UnixNano(), id)))
}
func decodeCursor(cursor string) (time.Time, int64, error) {
raw, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil {
return time.Time{}, 0, fmt.Errorf("decode cursor: %w", err)
}
parts := strings.Split(string(raw), "|")
if len(parts) != 2 {
return time.Time{}, 0, fmt.Errorf("invalid cursor")
}
var nanos int64
var id int64
if _, err := fmt.Sscanf(parts[0], "%d", &nanos); err != nil {
return time.Time{}, 0, fmt.Errorf("parse cursor timestamp: %w", err)
}
if _, err := fmt.Sscanf(parts[1], "%d", &id); err != nil {
return time.Time{}, 0, fmt.Errorf("parse cursor id: %w", err)
}
return time.Unix(0, nanos).UTC(), id, nil
}