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.
This commit is contained in:
1 parent
fe0cd4bf6d
commit
88d7d6d2b9
11 files changed
+2426
No files matched your search
+108
@@ -0,0 +1,108 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user