Files
mcr/internal/db/audit.go
Kyle Isom fde66be9c1 Phase 1: config loading, database migrations, audit log
- internal/config: TOML config with env overrides (MCR_ prefix),
  required field validation, same-filesystem check, defaults
- internal/db: SQLite via modernc.org/sqlite, WAL mode, 2 migrations
  (core registry tables + policy/audit), foreign key cascades
- internal/db: audit log write/list with filtering and pagination
- deploy/examples/mcr.toml: annotated example configuration
- .golangci.yaml: disable fieldalignment (readability over micro-opt)
- checkpoint skill copied from mcias

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:14:19 -07:00

148 lines
3.7 KiB
Go

package db
import (
"encoding/json"
"fmt"
"strings"
)
// AuditEvent represents a row in the audit_log table.
type AuditEvent struct {
ID int64 `json:"id"`
EventTime string `json:"event_time"`
EventType string `json:"event_type"`
ActorID string `json:"actor_id,omitempty"`
Repository string `json:"repository,omitempty"`
Digest string `json:"digest,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
// WriteAuditEvent inserts a new audit log entry.
func (d *DB) WriteAuditEvent(eventType, actorID, repository, digest, ip string, details map[string]string) error {
var detailsJSON *string
if len(details) > 0 {
b, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("db: marshal audit details: %w", err)
}
s := string(b)
detailsJSON = &s
}
_, err := d.Exec(
`INSERT INTO audit_log (event_type, actor_id, repository, digest, ip_address, details)
VALUES (?, ?, ?, ?, ?, ?)`,
eventType,
nullIfEmpty(actorID),
nullIfEmpty(repository),
nullIfEmpty(digest),
nullIfEmpty(ip),
detailsJSON,
)
if err != nil {
return fmt.Errorf("db: write audit event: %w", err)
}
return nil
}
// AuditFilter specifies criteria for listing audit events.
type AuditFilter struct {
EventType string
ActorID string
Repository string
Since string // RFC 3339
Until string // RFC 3339
Limit int
Offset int
}
// ListAuditEvents returns audit events matching the filter, ordered by
// event_time descending (most recent first).
func (d *DB) ListAuditEvents(f AuditFilter) ([]AuditEvent, error) {
var clauses []string
var args []any
if f.EventType != "" {
clauses = append(clauses, "event_type = ?")
args = append(args, f.EventType)
}
if f.ActorID != "" {
clauses = append(clauses, "actor_id = ?")
args = append(args, f.ActorID)
}
if f.Repository != "" {
clauses = append(clauses, "repository = ?")
args = append(args, f.Repository)
}
if f.Since != "" {
clauses = append(clauses, "event_time >= ?")
args = append(args, f.Since)
}
if f.Until != "" {
clauses = append(clauses, "event_time <= ?")
args = append(args, f.Until)
}
query := "SELECT id, event_time, event_type, actor_id, repository, digest, ip_address, details FROM audit_log"
if len(clauses) > 0 {
query += " WHERE " + strings.Join(clauses, " AND ")
}
query += " ORDER BY event_time DESC"
limit := f.Limit
if limit <= 0 {
limit = 50
}
query += fmt.Sprintf(" LIMIT %d", limit)
if f.Offset > 0 {
query += fmt.Sprintf(" OFFSET %d", f.Offset)
}
rows, err := d.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("db: list audit events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []AuditEvent
for rows.Next() {
var e AuditEvent
var actorID, repository, digest, ip, detailsStr *string
if err := rows.Scan(&e.ID, &e.EventTime, &e.EventType, &actorID, &repository, &digest, &ip, &detailsStr); err != nil {
return nil, fmt.Errorf("db: scan audit event: %w", err)
}
if actorID != nil {
e.ActorID = *actorID
}
if repository != nil {
e.Repository = *repository
}
if digest != nil {
e.Digest = *digest
}
if ip != nil {
e.IPAddress = *ip
}
if detailsStr != nil {
if err := json.Unmarshal([]byte(*detailsStr), &e.Details); err != nil {
return nil, fmt.Errorf("db: unmarshal audit details: %w", err)
}
}
events = append(events, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("db: iterate audit events: %w", err)
}
return events, nil
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}