Files
dpsapi/audit_store.go
bryanpedini 88d7d6d2b9 feat(core): add configuration, logging and sqlite persistence
DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
2026-10-06 10:52:25 +02:00

109 lines
3.5 KiB
Go

// 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
}