feat: activity log backend foundation

This commit is contained in:
fsylvain committed 2026-10-06 20:35:03 +02:00
1 parent 8907bb49fe
commit 50b1649965
74 files changed
+5949 -239

No files matched your search

@@ -7,7 +7,7 @@ import (
)
func Migrate(db *gorm.DB) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{})
if err != nil {
return err
}
@@ -0,0 +1,97 @@
package mappers
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
const (
emptyParams = "{}"
emptyChanges = "[]"
)
func AuditEventToModel(e audit.Event) (models.AuditEvent, error) {
params, err := encodeJSON(e.Params, emptyParams)
if err != nil {
return models.AuditEvent{}, err
}
changes, err := encodeJSON(e.Changes, emptyChanges)
if err != nil {
return models.AuditEvent{}, err
}
return models.AuditEvent{
ID: e.ID,
CreatedAt: e.CreatedAt.UTC(),
Action: e.Action,
Category: string(e.Category),
ActorType: string(e.Actor.Type),
ActorID: e.Actor.ID,
ActorName: e.Actor.Name,
TargetType: e.Target.Type,
TargetID: e.Target.ID,
TargetName: e.Target.Name,
Outcome: string(e.Outcome),
FailureReason: e.FailureReason,
Security: e.Security,
Source: string(e.Source),
IP: e.IP,
UserAgent: e.UserAgent,
Params: params,
Changes: changes,
}, nil
}
func AuditEventToDomain(m models.AuditEvent) (audit.Event, error) {
params := map[string]any{}
if err := json.Unmarshal([]byte(m.Params), &params); err != nil {
return audit.Event{}, err
}
changes := []audit.Change{}
if err := json.Unmarshal([]byte(m.Changes), &changes); err != nil {
return audit.Event{}, err
}
return audit.Event{
ID: m.ID,
CreatedAt: m.CreatedAt.UTC(),
Action: m.Action,
Category: audit.Category(m.Category),
Actor: audit.Actor{Type: audit.ActorType(m.ActorType), ID: m.ActorID, Name: m.ActorName},
Target: audit.Target{Type: m.TargetType, ID: m.TargetID, Name: m.TargetName},
Outcome: audit.Outcome(m.Outcome),
FailureReason: m.FailureReason,
Security: m.Security,
Source: audit.Source(m.Source),
IP: m.IP,
UserAgent: m.UserAgent,
Params: params,
Changes: changes,
}, nil
}
func encodeJSON(value any, empty string) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
if string(encoded) == "null" {
return empty, nil
}
return string(encoded), nil
}
func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
return audit.Settings{
RetentionDays: m.RetentionDays,
LastCleanupAt: m.LastCleanupAt,
LastCleanupRemoved: m.LastCleanupRemoved,
}
}
func AuditSettingsToModel(s audit.Settings) models.AuditSettings {
return models.AuditSettings{
RetentionDays: s.RetentionDays,
LastCleanupAt: s.LastCleanupAt,
LastCleanupRemoved: s.LastCleanupRemoved,
}
}
@@ -0,0 +1,24 @@
package models
import "time"
type AuditEvent struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
CreatedAt time.Time `gorm:"not null;index:idx_audit_events_created_at;index:idx_audit_events_actor,priority:3;index:idx_audit_events_category,priority:2"`
Action string `gorm:"not null;size:64"`
Category string `gorm:"not null;size:16;index:idx_audit_events_category,priority:1"`
ActorType string `gorm:"not null;size:16;index:idx_audit_events_actor,priority:1"`
ActorID *uint32 `gorm:"index:idx_audit_events_actor,priority:2"`
ActorName string `gorm:"not null;size:255"`
TargetType string `gorm:"not null;size:32"`
TargetID string `gorm:"not null;size:64"`
TargetName string `gorm:"not null;size:255"`
Outcome string `gorm:"not null;size:16"`
FailureReason *string `gorm:"size:255"`
Security bool `gorm:"not null"`
Source string `gorm:"not null;size:16"`
IP *string `gorm:"size:64"`
UserAgent *string `gorm:"size:255"`
Params string `gorm:"not null;type:text"`
Changes string `gorm:"not null;type:text"`
}
@@ -0,0 +1,10 @@
package models
import "time"
type AuditSettings struct {
ID uint32 `gorm:"primaryKey"`
RetentionDays int `gorm:"not null"`
LastCleanupAt *time.Time
LastCleanupRemoved int `gorm:"not null"`
}
@@ -0,0 +1,188 @@
package persistence
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"time"
"gorm.io/gorm"
)
const likeEscape = " ESCAPE '!'"
type AuditEventRepository struct {
db *gorm.DB
}
var _ audit.Repository = &AuditEventRepository{}
func NewAuditEventRepository(db *gorm.DB) *AuditEventRepository {
return &AuditEventRepository{db}
}
func (a *AuditEventRepository) Append(event audit.Event) (audit.Event, error) {
model, err := mappers.AuditEventToModel(event)
if err != nil {
return audit.Event{}, err
}
model.ID = 0
if err := a.db.Create(&model).Error; err != nil {
return audit.Event{}, err
}
return mappers.AuditEventToDomain(model)
}
func (a *AuditEventRepository) List(filter audit.Filter) (audit.Page, error) {
var total int64
if err := a.filtered(filter).Count(&total).Error; err != nil {
return audit.Page{}, err
}
query := a.filtered(filter).Order("created_at DESC, id DESC")
if filter.Size > 0 {
query = query.Limit(filter.Size).Offset(offsetOf(filter))
}
var found []entity.AuditEvent
if err := query.Find(&found).Error; err != nil {
return audit.Page{}, err
}
items := make([]audit.Event, 0, len(found))
for _, model := range found {
event, err := mappers.AuditEventToDomain(model)
if err != nil {
return audit.Page{}, err
}
items = append(items, event)
}
return audit.Page{Items: items, Total: int(total)}, nil
}
func offsetOf(filter audit.Filter) int {
if filter.Page < 1 {
return 0
}
return (filter.Page - 1) * filter.Size
}
func (a *AuditEventRepository) filtered(filter audit.Filter) *gorm.DB {
query := a.db.Model(&entity.AuditEvent{})
if filter.Actor != nil {
query = query.Where("actor_type = ?", string(filter.Actor.Type))
if filter.Actor.ID != nil {
query = query.Where("actor_id = ?", *filter.Actor.ID)
}
}
if filter.Category != "" {
query = query.Where("category = ?", string(filter.Category))
}
if filter.Outcome != "" {
query = query.Where("outcome = ?", string(filter.Outcome))
}
if filter.Security {
query = query.Where("security = ?", true)
}
if filter.From != nil {
query = query.Where("created_at >= ?", filter.From.UTC())
}
if filter.To != nil {
query = query.Where("created_at < ?", filter.To.UTC())
}
if filter.Query != "" {
pattern := containsPattern(strings.ToLower(filter.Query))
query = query.Where("(LOWER(actor_name) LIKE ?"+likeEscape+" OR LOWER(target_name) LIKE ?"+likeEscape+")", pattern, pattern)
}
return query
}
func (a *AuditEventRepository) Summary(query audit.SummaryQuery) (audit.Summary, error) {
var summary audit.Summary
totals, err := a.countsBetween(query.From, query.To)
if err != nil {
return audit.Summary{}, err
}
summary.Total, summary.Failed, summary.Security = totals.Total, totals.Failed, totals.Security
previous := query.Previous()
previousTotals, err := a.countsBetween(previous.From, previous.To)
if err != nil {
return audit.Summary{}, err
}
summary.PreviousTotal = previousTotals.Total
if summary.ActivePeople.Users, err = a.distinctActors(audit.ActorUser, query.From, query.To); err != nil {
return audit.Summary{}, err
}
if summary.ActivePeople.APIKeys, err = a.distinctActors(audit.ActorAPIKey, query.From, query.To); err != nil {
return audit.Summary{}, err
}
summary.Series, err = a.series(query)
return summary, err
}
type countsRow struct {
Total int
Failed int
Security int
}
func (a *AuditEventRepository) countsBetween(from time.Time, to time.Time) (countsRow, error) {
var row countsRow
err := a.db.Model(&entity.AuditEvent{}).
Select("COUNT(*) AS total, "+
"COALESCE(SUM(CASE WHEN outcome = ? THEN 1 ELSE 0 END), 0) AS failed, "+
"COALESCE(SUM(CASE WHEN security = ? THEN 1 ELSE 0 END), 0) AS security", string(audit.OutcomeFailed), true).
Where("created_at >= ? AND created_at < ?", from.UTC(), to.UTC()).
Scan(&row).Error
return row, err
}
func (a *AuditEventRepository) distinctActors(actorType audit.ActorType, from time.Time, to time.Time) (int, error) {
var count int64
err := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND created_at >= ? AND created_at < ?", string(actorType), from.UTC(), to.UTC()).
Distinct("actor_id").
Count(&count).Error
return int(count), err
}
func (a *AuditEventRepository) series(query audit.SummaryQuery) (audit.Series, error) {
series := audit.Series{
Total: make([]int, audit.SeriesBuckets),
Active: make([]int, audit.SeriesBuckets),
Failed: make([]int, audit.SeriesBuckets),
Security: make([]int, audit.SeriesBuckets),
}
width := query.To.Sub(query.From) / audit.SeriesBuckets
if width <= 0 {
return series, nil
}
for i := 0; i < audit.SeriesBuckets; i++ {
from := query.From.Add(time.Duration(i) * width)
to := from.Add(width)
if i == audit.SeriesBuckets-1 {
to = query.To
}
counts, err := a.countsBetween(from, to)
if err != nil {
return audit.Series{}, err
}
users, err := a.distinctActors(audit.ActorUser, from, to)
if err != nil {
return audit.Series{}, err
}
keys, err := a.distinctActors(audit.ActorAPIKey, from, to)
if err != nil {
return audit.Series{}, err
}
series.Total[i], series.Failed[i], series.Security[i], series.Active[i] = counts.Total, counts.Failed, counts.Security, users+keys
}
return series, nil
}
func (a *AuditEventRepository) DeleteOlderThan(cutoff time.Time) (int, error) {
result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{})
return int(result.RowsAffected), result.Error
}
@@ -0,0 +1,61 @@
package persistence
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"time"
"gorm.io/gorm"
)
const auditSettingsRowID uint32 = 1
type AuditSettingsRepository struct {
db *gorm.DB
}
var _ audit.SettingsRepository = &AuditSettingsRepository{}
func NewAuditSettingsRepository(db *gorm.DB) *AuditSettingsRepository {
return &AuditSettingsRepository{db}
}
func (a *AuditSettingsRepository) Get() (audit.Settings, error) {
var model entity.AuditSettings
err := a.db.First(&model, auditSettingsRowID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return audit.DefaultSettings(), nil
}
if err != nil {
return audit.Settings{}, err
}
return mappers.AuditSettingsToDomain(model), nil
}
func (a *AuditSettingsRepository) SaveRetention(days int) error {
return a.update(map[string]any{"retention_days": days}, func(settings *audit.Settings) {
settings.RetentionDays = days
})
}
func (a *AuditSettingsRepository) SaveLastCleanup(at time.Time, removed int) error {
utc := at.UTC()
return a.update(map[string]any{"last_cleanup_at": utc, "last_cleanup_removed": removed}, func(settings *audit.Settings) {
settings.LastCleanupAt = &utc
settings.LastCleanupRemoved = removed
})
}
func (a *AuditSettingsRepository) update(columns map[string]any, apply func(*audit.Settings)) error {
result := a.db.Model(&entity.AuditSettings{}).Where("id = ?", auditSettingsRowID).Updates(columns)
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
settings := audit.DefaultSettings()
apply(&settings)
model := mappers.AuditSettingsToModel(settings)
model.ID = auditSettingsRowID
return a.db.Create(&model).Error
}