feat: activity log backend foundation
This commit is contained in:
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), ¶ms); 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
|
||||
}
|
||||
Reference in new issue
Block a user