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

@@ -0,0 +1,57 @@
package auditing
import (
"RenewCMS/internal/domain/audit"
"context"
"log"
"time"
)
type Recorder struct {
repository audit.Repository
now func() time.Time
logger *log.Logger
}
var _ audit.Recorder = &Recorder{}
func NewRecorder(repository audit.Repository, now func() time.Time, logger *log.Logger) *Recorder {
return &Recorder{repository, now, logger}
}
func (r *Recorder) Record(ctx context.Context, event audit.Event) {
defer func() {
if recovered := recover(); recovered != nil {
r.logger.Printf("audit: writing the %s event panicked: %v", event.Action, recovered)
}
}()
event = r.complete(ctx, event).Truncated()
if err := event.Validate(); err != nil {
r.logger.Printf("audit: dropped the %s event: %v", event.Action, err)
return
}
if _, err := r.repository.Append(event); err != nil {
r.logger.Printf("audit: failed to write the %s event: %v", event.Action, err)
}
}
func (r *Recorder) complete(ctx context.Context, event audit.Event) audit.Event {
origin, _ := audit.OriginFrom(ctx)
if event.Actor.Type == "" {
event.Actor = origin.Actor
}
if event.Source == "" {
event.Source = origin.Source
}
if event.IP == nil && origin.IP != "" {
ip := origin.IP
event.IP = &ip
}
if event.UserAgent == nil && origin.UserAgent != "" {
userAgent := origin.UserAgent
event.UserAgent = &userAgent
}
event.CreatedAt = r.now().UTC()
return event
}
@@ -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
}
+64 -9
View File
@@ -1,9 +1,11 @@
package scheduler
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/purge"
"context"
"errors"
"fmt"
"log"
"time"
)
@@ -17,8 +19,39 @@ type Job interface {
Tick(now time.Time) (purge.RunResult, bool, error)
}
type CleanupJob interface {
Tick(now time.Time) (audit.CleanupResult, bool, error)
}
type task interface {
tick(now time.Time) (summary string, ran bool, err error)
}
type purgeTask struct {
job Job
}
func (p purgeTask) tick(now time.Time) (string, bool, error) {
result, ran, err := p.job.Tick(now)
if errors.Is(err, purge.ErrPurgeBusy) {
return "", false, nil
}
summary := fmt.Sprintf("groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups)
return summary, ran, err
}
type cleanupTask struct {
job CleanupJob
}
func (c cleanupTask) tick(now time.Time) (string, bool, error) {
result, ran, err := c.job.Tick(now)
return fmt.Sprintf("removed=%d", result.Removed), ran, err
}
type Runner struct {
job Job
label string
task task
now func() time.Time
ticks <-chan time.Time
startup <-chan time.Time
@@ -26,14 +59,39 @@ type Runner struct {
release func()
}
const (
purgeLabel = "automatic purge"
cleanupLabel = "activity cleanup"
)
func newRunner(label string, task task, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return &Runner{label: label, task: task, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}}
}
func NewRunner(job Job, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return &Runner{job: job, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}}
return newRunner(purgeLabel, purgeTask{job}, now, ticks, startup, logger)
}
func NewCleanupRunner(job CleanupJob, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return newRunner(cleanupLabel, cleanupTask{job}, now, ticks, startup, logger)
}
func NewSystemRunner(job Job, logger *log.Logger) *Runner {
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
return NewRunner(job, now, ticks, startup, logger)
})
}
func NewSystemCleanupRunner(job CleanupJob, logger *log.Logger) *Runner {
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
return NewCleanupRunner(job, now, ticks, startup, logger)
})
}
func withSystemTimers(build func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner) *Runner {
ticker := time.NewTicker(TickInterval)
startup := time.NewTimer(StartupDelay)
runner := NewRunner(job, time.Now, ticker.C, startup.C, logger)
runner := build(time.Now, ticker.C, startup.C)
runner.release = func() {
ticker.Stop()
startup.Stop()
@@ -61,15 +119,12 @@ func (r *Runner) tick(ctx context.Context) {
if ctx.Err() != nil {
return
}
result, ran, err := r.job.Tick(r.now())
if errors.Is(err, purge.ErrPurgeBusy) {
return
}
summary, ran, err := r.task.tick(r.now())
if err != nil {
r.logger.Printf("scheduler: automatic purge failed: %v", err)
r.logger.Printf("scheduler: %s failed: %v", r.label, err)
return
}
if ran {
r.logger.Printf("scheduler: automatic purge completed: groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups)
r.logger.Printf("scheduler: %s completed: %s", r.label, summary)
}
}
@@ -0,0 +1,50 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"context"
)
type GetActivityRetentionUseCase struct {
settings audit.SettingsRepository
}
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository) *GetActivityRetentionUseCase {
return &GetActivityRetentionUseCase{settings}
}
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
return g.settings.Get()
}
type UpdateActivityRetentionUseCase struct {
settings audit.SettingsRepository
recorder audit.Recorder
}
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder) *UpdateActivityRetentionUseCase {
return &UpdateActivityRetentionUseCase{settings, recorder}
}
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) {
if err := audit.ValidateRetentionDays(days); err != nil {
return audit.Settings{}, err
}
current, err := u.settings.Get()
if err != nil {
return audit.Settings{}, err
}
if current.RetentionDays == days {
return current, nil
}
if err := u.settings.SaveRetention(days); err != nil {
return audit.Settings{}, err
}
event := audit.NewEvent(audit.ActionRetentionChanged, audit.CategorySystem)
event.Params = map[string]any{audit.ParamFrom: current.RetentionDays, audit.ParamTo: days}
u.recorder.Record(ctx, event)
current.RetentionDays = days
return current, nil
}
@@ -1,20 +1,37 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type AuthenticateUserUseCase struct {
userRepository user.Repository
hasher hash.Hasher
recorder audit.Recorder
}
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher) *AuthenticateUserUseCase {
return &AuthenticateUserUseCase{userRepository, hasher}
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher, recorder audit.Recorder) *AuthenticateUserUseCase {
return &AuthenticateUserUseCase{userRepository, hasher, recorder}
}
func (a *AuthenticateUserUseCase) Authenticate(username string, password string) (user.User, error) {
func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username string, password string) (user.User, error) {
dbUser, err := a.verifyCredentials(username, password)
if err != nil {
a.recordRejectedLogin(ctx, username, err)
return user.User{}, err
}
event := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
event.Actor = audit.UserActor(dbUser)
a.recorder.Record(ctx, event)
return dbUser, nil
}
func (a *AuthenticateUserUseCase) verifyCredentials(username string, password string) (user.User, error) {
dbUser, err := a.userRepository.GetByUsername(username)
if err != nil {
return user.User{}, err
@@ -26,3 +43,24 @@ func (a *AuthenticateUserUseCase) Authenticate(username string, password string)
return dbUser, nil
}
func (a *AuthenticateUserUseCase) recordRejectedLogin(ctx context.Context, username string, cause error) {
if !errors.Is(cause, user.ErrUserNotFound) && !errors.Is(cause, user.ErrInvalidCredentials) {
return
}
reason := audit.ReasonWrongLogin
event := audit.NewEvent(audit.ActionLoginFailed, audit.CategoryAuth)
event.Actor = audit.AnonymousActor(username)
event.Outcome = audit.OutcomeFailed
event.FailureReason = &reason
event.Security = true
a.recorder.Record(ctx, event)
}
func (a *AuthenticateUserUseCase) Logout(ctx context.Context) {
origin, ok := audit.OriginFrom(ctx)
if !ok || origin.Actor.Type != audit.ActorUser {
return
}
a.recorder.Record(ctx, audit.NewEvent(audit.ActionLogout, audit.CategoryAuth))
}
@@ -0,0 +1,47 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"context"
"time"
)
type CleanupActivityUseCase struct {
events audit.Repository
settings audit.SettingsRepository
recorder audit.Recorder
}
func NewCleanupActivityUseCase(events audit.Repository, settings audit.SettingsRepository, recorder audit.Recorder) *CleanupActivityUseCase {
return &CleanupActivityUseCase{events, settings, recorder}
}
func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool, error) {
settings, err := c.settings.Get()
if err != nil {
return audit.CleanupResult{}, false, err
}
if settings.RetentionDays == audit.ForeverRetentionDays || !settings.CleanupDue(now) {
return audit.CleanupResult{}, false, nil
}
removed, err := c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
if err != nil {
return audit.CleanupResult{}, false, err
}
if err := c.settings.SaveLastCleanup(now, removed); err != nil {
return audit.CleanupResult{}, false, err
}
if removed > 0 {
c.recordCleanup(removed)
}
return audit.CleanupResult{Removed: removed}, true, nil
}
func (c *CleanupActivityUseCase) recordCleanup(removed int) {
event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem)
event.Actor = audit.SystemActor()
event.Source = audit.SourceSystem
event.Params = map[string]any{audit.ParamRemoved: removed}
c.recorder.Record(context.Background(), event)
}
@@ -2,6 +2,8 @@ package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/audit"
"context"
"time"
)
@@ -9,6 +11,7 @@ type CreateAPIKeyUseCase struct {
apiKeyRepository apikey.Repository
keyGenerator apikey.KeyGenerator
now func() time.Time
recorder audit.Recorder
}
type CreateAPIKeyCommand struct {
@@ -22,11 +25,11 @@ type CreatedAPIKey struct {
RawKey string
}
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time) *CreateAPIKeyUseCase {
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now}
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time, recorder audit.Recorder) *CreateAPIKeyUseCase {
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now, recorder}
}
func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (CreatedAPIKey, error) {
func (c *CreateAPIKeyUseCase) CreateAPIKey(ctx context.Context, command CreateAPIKeyCommand) (CreatedAPIKey, error) {
now := c.now()
if command.ExpiresAt != nil && !command.ExpiresAt.After(now) {
return CreatedAPIKey{}, apikey.ErrExpiryNotInFuture
@@ -45,5 +48,9 @@ func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (Created
return CreatedAPIKey{}, err
}
event := audit.NewEvent(audit.ActionAPIKeyCreate, audit.CategoryAPIKeys)
event.Target = audit.APIKeyTarget(stored.ID, stored.Name)
c.recorder.Record(ctx, event)
return CreatedAPIKey{Key: stored, RawKey: rawKey}, nil
}
@@ -0,0 +1,88 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"encoding/csv"
"io"
"strings"
"time"
)
const exportBatchSize = 500
const formulaGuard = "'"
const formulaStarts = "=+-@"
var exportHeader = []string{"when", "actor", "action", "target", "outcome", "source", "ip"}
type ExportActivityUseCase struct {
events audit.Repository
now func() time.Time
}
type ActivityExport struct {
events audit.Repository
filter audit.Filter
}
func NewExportActivityUseCase(events audit.Repository, now func() time.Time) *ExportActivityUseCase {
return &ExportActivityUseCase{events, now}
}
func (e *ExportActivityUseCase) Prepare(query ActivityQuery) (*ActivityExport, error) {
filter, err := buildActivityFilter(query, e.now())
if err != nil {
return nil, err
}
return &ActivityExport{events: e.events, filter: filter}, nil
}
func (a *ActivityExport) WriteCSV(w io.Writer) error {
writer := csv.NewWriter(w)
if err := writer.Write(exportHeader); err != nil {
return err
}
for page := 1; ; page++ {
filter := a.filter
filter.Page, filter.Size = page, exportBatchSize
found, err := a.events.List(filter)
if err != nil {
return err
}
for _, event := range found.Items {
if err := writer.Write(exportRow(event)); err != nil {
return err
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return err
}
if len(found.Items) < exportBatchSize || page*exportBatchSize >= found.Total {
return nil
}
}
}
func exportRow(event audit.Event) []string {
ip := ""
if event.IP != nil {
ip = *event.IP
}
return []string{
event.CreatedAt.UTC().Format(time.RFC3339),
guardCell(event.Actor.Name),
guardCell(event.Action),
guardCell(event.Target.Name),
string(event.Outcome),
string(event.Source),
guardCell(ip),
}
}
func guardCell(value string) string {
if value != "" && strings.ContainsRune(formulaStarts, rune(value[0])) {
return formulaGuard + value
}
return value
}
@@ -0,0 +1,23 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"time"
)
type GetActivitySummaryUseCase struct {
events audit.Repository
now func() time.Time
}
func NewGetActivitySummaryUseCase(events audit.Repository, now func() time.Time) *GetActivitySummaryUseCase {
return &GetActivitySummaryUseCase{events, now}
}
func (g *GetActivitySummaryUseCase) GetActivitySummary(query ActivityQuery) (audit.Summary, error) {
window, err := resolveActivityWindow(query, g.now())
if err != nil {
return audit.Summary{}, err
}
return g.events.Summary(audit.SummaryQuery{From: window.from, To: window.to})
}
@@ -0,0 +1,146 @@
package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"errors"
"strconv"
"time"
)
const (
targetArticle = "article"
targetImage = "image"
)
type ListActivityUseCase struct {
events audit.Repository
users user.Repository
articles article.Repository
images image.Repository
apiKeys apikey.Repository
now func() time.Time
}
type ListActivityCommand struct {
ViewerID uint32
Query ActivityQuery
}
type ActivityEntry struct {
Event audit.Event
TargetExists bool
}
type ActivityPage struct {
Items []ActivityEntry
Total int
Page int
Size int
}
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time) *ListActivityUseCase {
return &ListActivityUseCase{events, users, articles, images, apiKeys, now}
}
func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
filter, err := buildActivityFilter(command.Query, l.now())
if err != nil {
return ActivityPage{}, err
}
if filter.Page, filter.Size, err = resolveActivityPaging(command.Query); err != nil {
return ActivityPage{}, err
}
if err := l.scopeToViewer(&filter, command.ViewerID); err != nil {
return ActivityPage{}, err
}
found, err := l.events.List(filter)
if err != nil {
return ActivityPage{}, err
}
entries, err := l.withTargetState(found.Items)
if err != nil {
return ActivityPage{}, err
}
return ActivityPage{Items: entries, Total: found.Total, Page: filter.Page, Size: filter.Size}, nil
}
func (l *ListActivityUseCase) scopeToViewer(filter *audit.Filter, viewerID uint32) error {
viewer, err := l.users.Get(viewerID)
if err != nil {
return err
}
if viewer.Role == user.RoleOwner || viewer.Role == user.RoleAdmin {
return nil
}
own := audit.ActorRef{Type: audit.ActorUser, ID: &viewer.ID}
if filter.Actor != nil && !sameActor(*filter.Actor, own) {
return audit.ErrActorNotAllowed
}
filter.Actor = &own
return nil
}
func sameActor(a audit.ActorRef, b audit.ActorRef) bool {
return a.Type == b.Type && a.ID != nil && b.ID != nil && *a.ID == *b.ID
}
type targetKey struct {
kind string
id string
}
func (l *ListActivityUseCase) withTargetState(events []audit.Event) ([]ActivityEntry, error) {
known := map[targetKey]bool{}
entries := make([]ActivityEntry, 0, len(events))
for _, event := range events {
key := targetKey{event.Target.Type, event.Target.ID}
exists, resolved := known[key]
if !resolved {
var err error
if exists, err = l.targetExists(event.Target); err != nil {
return nil, err
}
known[key] = exists
}
entries = append(entries, ActivityEntry{Event: event, TargetExists: exists})
}
return entries, nil
}
func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
id, err := strconv.ParseUint(target.ID, 10, 32)
if err != nil {
return true, nil
}
switch target.Type {
case targetArticle:
_, err = l.articles.Get(uint32(id))
return existsUnless(err, article.ErrArticleNotFound)
case audit.TargetUser:
_, err = l.users.Get(uint32(id))
return existsUnless(err, user.ErrUserNotFound)
case audit.TargetAPIKey:
_, err = l.apiKeys.Get(uint32(id))
return existsUnless(err, apikey.ErrAPIKeyNotFound)
case targetImage:
_, err = l.images.Get(uint32(id))
return existsUnless(err, image.ErrImageNotFound)
default:
return true, nil
}
}
func existsUnless(err error, notFound error) (bool, error) {
if err == nil {
return true, nil
}
if errors.Is(err, notFound) {
return false, nil
}
return false, err
}
@@ -1,9 +1,11 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"context"
"log"
"time"
@@ -15,6 +17,7 @@ type RequestPasswordResetUseCase struct {
hasher hash.Hasher
mailRepository mail.Repository
publicURL string
recorder audit.Recorder
}
type MailPasswordResetData struct {
@@ -23,11 +26,11 @@ type MailPasswordResetData struct {
Email string
}
func NewRequestPasswordResetUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *RequestPasswordResetUseCase {
return &RequestPasswordResetUseCase{userRepository, hasher, mailRepository, publicURL}
func NewRequestPasswordResetUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RequestPasswordResetUseCase {
return &RequestPasswordResetUseCase{userRepository, hasher, mailRepository, publicURL, recorder}
}
func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
func (g *RequestPasswordResetUseCase) RequestPasswordReset(ctx context.Context, email string) error {
foundUser, err := g.userRepository.GetByEmail(email)
if err != nil {
return nil
@@ -46,6 +49,8 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
return nil
}
g.recordRequest(ctx, foundUser)
if err := g.mailRepository.Send(foundUser.Email, "password-reset", MailPasswordResetData{
Host: g.publicURL,
VerificationCode: rawCode,
@@ -57,3 +62,10 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
return nil
}
func (g *RequestPasswordResetUseCase) recordRequest(ctx context.Context, requester user.User) {
event := audit.NewEvent(audit.ActionPasswordResetRequest, audit.CategoryAuth)
event.Actor = audit.UserActor(requester)
event.Security = true
g.recorder.Record(ctx, event)
}
@@ -2,8 +2,10 @@ package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"context"
"time"
)
@@ -12,13 +14,14 @@ type ResetPasswordUseCase struct {
hasher hash.Hasher
apiKeys apikey.Repository
now func() time.Time
recorder audit.Recorder
}
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time, recorder audit.Recorder) *ResetPasswordUseCase {
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now, recorder}
}
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
func (r *ResetPasswordUseCase) ResetPassword(ctx context.Context, email string, code string, newPassword string) error {
dbUser, err := r.userRepository.GetByEmail(email)
if err != nil {
return user.ErrInvalidPasswordResetCode
@@ -41,5 +44,13 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
return err
}
return r.apiKeys.RevokeAllByUser(dbUser.ID, r.now())
if err := r.apiKeys.RevokeAllByUser(dbUser.ID, r.now()); err != nil {
return err
}
event := audit.NewEvent(audit.ActionPasswordResetDone, audit.CategoryAuth)
event.Actor = audit.UserActor(dbUser)
event.Security = true
r.recorder.Record(ctx, event)
return nil
}
@@ -2,7 +2,9 @@ package useCases
import (
"RenewCMS/internal/domain/apikey"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"context"
"time"
)
@@ -10,13 +12,14 @@ type RevokeAPIKeyUseCase struct {
apiKeyRepository apikey.Repository
userRepository user.Repository
now func() time.Time
recorder audit.Recorder
}
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *RevokeAPIKeyUseCase {
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now}
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase {
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now, recorder}
}
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) error {
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32) error {
key, err := r.apiKeyRepository.Get(keyId)
if err != nil {
return err
@@ -35,5 +38,12 @@ func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) err
return nil
}
return r.apiKeyRepository.Revoke(key.ID, r.now())
if err := r.apiKeyRepository.Revoke(key.ID, r.now()); err != nil {
return err
}
event := audit.NewEvent(audit.ActionAPIKeyRevoke, audit.CategoryAPIKeys)
event.Target = audit.APIKeyTarget(key.ID, key.Name)
r.recorder.Record(ctx, event)
return nil
}
@@ -0,0 +1,144 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"time"
)
const (
activityRangeToday = "today"
activityRange7Days = "7d"
activityRange30Days = "30d"
activityRangeCustom = "custom"
activityDateLayout = "2006-01-02"
activityDefaultSize = 25
outcomeAll = "all"
securityOnly = "true"
)
var activityPageSizes = []int{25, 50, 100}
type ActivityQuery struct {
Q string
Actor string
Category string
Outcome string
Security string
Range string
From string
To string
Page int
Size int
}
type activityWindow struct {
from time.Time
to time.Time
explicit bool
}
func resolveActivityWindow(query ActivityQuery, now time.Time) (activityWindow, error) {
utc := now.UTC()
end := utc.Add(time.Second)
switch query.Range {
case "", activityRange7Days:
return activityWindow{from: end.AddDate(0, 0, -7), to: end}, nil
case activityRange30Days:
return activityWindow{from: end.AddDate(0, 0, -30), to: end}, nil
case activityRangeToday:
return activityWindow{from: time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC), to: end}, nil
case activityRangeCustom:
return resolveCustomWindow(query)
default:
return activityWindow{}, audit.ErrInvalidFilter
}
}
func resolveCustomWindow(query ActivityQuery) (activityWindow, error) {
from, err := time.Parse(activityDateLayout, query.From)
if err != nil {
return activityWindow{}, audit.ErrInvalidFilter
}
last, err := time.Parse(activityDateLayout, query.To)
if err != nil || last.Before(from) {
return activityWindow{}, audit.ErrInvalidFilter
}
return activityWindow{from: from, to: last.AddDate(0, 0, 1), explicit: true}, nil
}
func buildActivityFilter(query ActivityQuery, now time.Time) (audit.Filter, error) {
window, err := resolveActivityWindow(query, now)
if err != nil {
return audit.Filter{}, err
}
filter := audit.Filter{Query: query.Q, From: &window.from}
if window.explicit {
filter.To = &window.to
}
if filter.Outcome, err = parseActivityOutcome(query.Outcome); err != nil {
return audit.Filter{}, err
}
if filter.Security, err = parseActivitySecurity(query.Security); err != nil {
return audit.Filter{}, err
}
if query.Category != "" {
if filter.Category, err = audit.ParseCategory(query.Category); err != nil {
return audit.Filter{}, err
}
}
if query.Actor != "" {
actor, err := audit.ParseActor(query.Actor)
if err != nil {
return audit.Filter{}, err
}
filter.Actor = &actor
}
return filter, nil
}
func parseActivityOutcome(raw string) (audit.Outcome, error) {
switch raw {
case "", outcomeAll:
return "", nil
case string(audit.OutcomeSuccess):
return audit.OutcomeSuccess, nil
case string(audit.OutcomeFailed):
return audit.OutcomeFailed, nil
default:
return "", audit.ErrInvalidFilter
}
}
func parseActivitySecurity(raw string) (bool, error) {
switch raw {
case "":
return false, nil
case securityOnly:
return true, nil
default:
return false, audit.ErrInvalidFilter
}
}
func resolveActivityPaging(query ActivityQuery) (int, int, error) {
page, size := query.Page, query.Size
if page == 0 {
page = 1
}
if size == 0 {
size = activityDefaultSize
}
if page < 1 || !containsInt(activityPageSizes, size) {
return 0, 0, audit.ErrInvalidFilter
}
return page, size, nil
}
func containsInt(values []int, wanted int) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}