// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini package main import ( "cmp" "context" "database/sql" "fmt" "log/slog" "slices" "time" ) // operationGetContainers is the only audited operation const operationGetContainers = "GET containers" // AuditStore persists audit rows in the operations table type AuditStore struct { db *sql.DB log *slog.Logger now func() time.Time } // NewAuditStore creates an audit store using the given clock func NewAuditStore(db *sql.DB, log *slog.Logger, now func() time.Time) *AuditStore { return &AuditStore{db: db, log: log, now: now} } // RecordOperation inserts one operations row stamped with the current UTC time func (a *AuditStore) RecordOperation(ctx context.Context, token, origin, operation string) error { defer traceFunc(ctx, a.log, "AuditStore.RecordOperation")() at := formatTime(a.now()) if _, err := a.db.ExecContext(ctx, `INSERT INTO operations (token, origin, operation, time) VALUES (?, ?, ?, ?)`, token, origin, operation, at); err != nil { return fmt.Errorf("insert operation: %w", err) } trace(ctx, a.log, "row inserted", "table", "operations", "token_id", tokenID(token), "origin", origin, "operation", operation, "time", at) return nil } // Operation is one row of the operations table type Operation struct { RowID int64 Token string Origin string Operation string Time time.Time // parsed, used for ordering and filtering TimeText string // exactly as stored } // OperationQuery selects audit rows; the zero value selects every row type OperationQuery struct { Limit int // > 0: keep only the Limit latest matching rows Since *time.Time // non-nil: keep only rows with time >= *Since } // compareOperations orders rows by parsed time, ties broken by insertion order (rowid) func compareOperations(a, b Operation) int { if c := a.Time.Compare(b.Time); c != 0 { return c } return cmp.Compare(a.RowID, b.RowID) } // ListOperations returns the rows matching q in chronological order (oldest first) // With a Limit, the returned rows are the Limit latest ones by (time, rowid), still oldest first func (a *AuditStore) ListOperations(ctx context.Context, q OperationQuery) ([]Operation, error) { defer traceFunc(ctx, a.log, "AuditStore.ListOperations")() rows, err := a.db.QueryContext(ctx, `SELECT rowid, token, origin, operation, time FROM operations`) if err != nil { return nil, fmt.Errorf("query operations: %w", err) } defer rows.Close() ops := []Operation{} for rows.Next() { var op Operation if err := rows.Scan(&op.RowID, &op.Token, &op.Origin, &op.Operation, &op.TimeText); err != nil { return nil, fmt.Errorf("read operation row: %w", err) } // A row whose time does not parse cannot be ordered: fail rather than misorder if op.Time, err = parseTime(op.TimeText); err != nil { return nil, fmt.Errorf("read operation row: %w", err) } trace(ctx, a.log, "row read", "table", "operations", "rowid", op.RowID, "token_id", tokenID(op.Token), "origin", op.Origin, "operation", op.Operation, "time", op.TimeText) if q.Since != nil && op.Time.Before(*q.Since) { continue } ops = append(ops, op) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate operation rows: %w", err) } // Sort in Go: RFC3339Nano strings with variable fraction length do not sort lexically slices.SortFunc(ops, compareOperations) if q.Limit > 0 && len(ops) > q.Limit { ops = ops[len(ops)-q.Limit:] } return ops, nil }