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

+207
View File
@@ -0,0 +1,207 @@
package audit
import (
"RenewCMS/internal/domain/user"
"errors"
"slices"
"strconv"
"strings"
"time"
)
var ErrInvalidFilter = errors.New("invalid activity filter")
var ErrInvalidRetention = errors.New("invalid activity retention period")
var ErrInvalidEvent = errors.New("invalid activity event")
var ErrActorNotAllowed = errors.New("activity of another actor is not allowed")
type ActorType string
const (
ActorUser ActorType = "user"
ActorAPIKey ActorType = "api_key"
ActorSystem ActorType = "system"
ActorAnonymous ActorType = "anonymous"
)
type Category string
const (
CategoryArticles Category = "articles"
CategoryMedia Category = "media"
CategoryUsers Category = "users"
CategoryAPIKeys Category = "api_keys"
CategoryAuth Category = "auth"
CategorySystem Category = "system"
)
type Outcome string
const (
OutcomeSuccess Outcome = "success"
OutcomeFailed Outcome = "failed"
)
type Source string
const (
SourceWeb Source = "web"
SourceAPIKey Source = "api_key"
SourceSystem Source = "system"
)
type ChangeKind string
const (
ChangeText ChangeKind = "text"
ChangeBody ChangeKind = "body"
)
const (
ActionLogin = "auth.login"
ActionLoginFailed = "auth.login_failed"
ActionLogout = "auth.logout"
ActionPasswordResetRequest = "auth.password_reset_requested"
ActionPasswordResetDone = "auth.password_reset_completed"
ActionAPIKeyCreate = "api_key.create"
ActionAPIKeyRevoke = "api_key.revoke"
ActionRetentionChanged = "system.activity_retention_changed"
ActionCleanup = "system.activity_cleanup"
)
const (
TargetAPIKey = "api_key"
TargetUser = "user"
)
const (
SystemActorName = "System"
ReasonWrongLogin = "Wrong username or password."
MaxUserAgentLength = 255
ParamRemoved = "removed"
ParamFrom = "from"
ParamTo = "to"
maxNameLength = 255
maxActionLength = 64
maxFailureReasonSize = 255
)
func ActorTypes() []ActorType {
return []ActorType{ActorUser, ActorAPIKey, ActorSystem, ActorAnonymous}
}
func Categories() []Category {
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem}
}
func Outcomes() []Outcome {
return []Outcome{OutcomeSuccess, OutcomeFailed}
}
func Sources() []Source {
return []Source{SourceWeb, SourceAPIKey, SourceSystem}
}
func ChangeKinds() []ChangeKind {
return []ChangeKind{ChangeText, ChangeBody}
}
type Actor struct {
Type ActorType `json:"type"`
ID *uint32 `json:"id"`
Name string `json:"name"`
}
type Target struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
type Change struct {
Field string `json:"field"`
Kind ChangeKind `json:"kind"`
Before *string `json:"before,omitempty"`
After *string `json:"after,omitempty"`
Delta *int `json:"delta,omitempty"`
}
type Event struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category Category `json:"category"`
Actor Actor `json:"actor"`
Target Target `json:"target"`
Outcome Outcome `json:"outcome"`
FailureReason *string `json:"failure_reason"`
Security bool `json:"security"`
Source Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
Changes []Change `json:"changes"`
}
func NewEvent(action string, category Category) Event {
return Event{
Action: action,
Category: category,
Outcome: OutcomeSuccess,
Params: map[string]any{},
Changes: []Change{},
}
}
func SystemActor() Actor {
return Actor{Type: ActorSystem, Name: SystemActorName}
}
func UserActor(u user.User) Actor {
id := u.ID
name := strings.TrimSpace(u.DisplayName)
if name == "" {
name = u.Username
}
return Actor{Type: ActorUser, ID: &id, Name: name}
}
func AnonymousActor(name string) Actor {
return Actor{Type: ActorAnonymous, Name: name}
}
func APIKeyActor(id uint32, name string) Actor {
return Actor{Type: ActorAPIKey, ID: &id, Name: name}
}
func APIKeyTarget(id uint32, name string) Target {
return Target{Type: TargetAPIKey, ID: strconv.FormatUint(uint64(id), 10), Name: name}
}
func (e Event) Validate() error {
if e.Action == "" || len(e.Action) > maxActionLength {
return ErrInvalidEvent
}
if !slices.Contains(Categories(), e.Category) || !slices.Contains(Outcomes(), e.Outcome) || !slices.Contains(Sources(), e.Source) || !slices.Contains(ActorTypes(), e.Actor.Type) {
return ErrInvalidEvent
}
if e.FailureReason != nil && len(*e.FailureReason) > maxFailureReasonSize {
return ErrInvalidEvent
}
for _, change := range e.Changes {
if change.Kind != ChangeText && change.Kind != ChangeBody {
return ErrInvalidEvent
}
}
return nil
}
func (e Event) Truncated() Event {
e.Actor.Name = truncate(e.Actor.Name, maxNameLength)
e.Target.Name = truncate(e.Target.Name, maxNameLength)
return e
}
func truncate(value string, limit int) string {
runes := []rune(value)
return string(runes[:min(len(runes), limit)])
}
+98
View File
@@ -0,0 +1,98 @@
package audit
import (
"slices"
"strconv"
"strings"
"time"
)
const SeriesBuckets = 7
type ActorRef struct {
Type ActorType
ID *uint32
}
const (
userActorPrefix = "user:"
apiKeyActorPrefix = "key:"
systemActorRef = "system"
)
func ParseActor(raw string) (ActorRef, error) {
if raw == systemActorRef {
return ActorRef{Type: ActorSystem}, nil
}
if rest, ok := strings.CutPrefix(raw, userActorPrefix); ok {
return parseActorID(ActorUser, rest)
}
if rest, ok := strings.CutPrefix(raw, apiKeyActorPrefix); ok {
return parseActorID(ActorAPIKey, rest)
}
return ActorRef{}, ErrInvalidFilter
}
func parseActorID(actorType ActorType, raw string) (ActorRef, error) {
id, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return ActorRef{}, ErrInvalidFilter
}
value := uint32(id)
return ActorRef{Type: actorType, ID: &value}, nil
}
func ParseCategory(raw string) (Category, error) {
category := Category(raw)
if !slices.Contains(Categories(), category) {
return "", ErrInvalidFilter
}
return category, nil
}
type Filter struct {
Query string
Actor *ActorRef
Category Category
Outcome Outcome
Security bool
From *time.Time
To *time.Time
Page int
Size int
}
type Page struct {
Items []Event
Total int
}
type SummaryQuery struct {
From time.Time
To time.Time
}
func (q SummaryQuery) Previous() SummaryQuery {
return SummaryQuery{From: q.From.Add(-q.To.Sub(q.From)), To: q.From}
}
type ActivePeople struct {
Users int `json:"users"`
APIKeys int `json:"api_keys"`
}
type Series struct {
Total []int `json:"total"`
Active []int `json:"active"`
Failed []int `json:"failed"`
Security []int `json:"security"`
}
type Summary struct {
Total int `json:"total"`
PreviousTotal int `json:"previous_total"`
ActivePeople ActivePeople `json:"active_people"`
Failed int `json:"failed"`
Security int `json:"security"`
Series Series `json:"series"`
}
+31
View File
@@ -0,0 +1,31 @@
package audit
import "context"
type Recorder interface {
Record(ctx context.Context, event Event)
}
type discardRecorder struct{}
func (discardRecorder) Record(context.Context, Event) {}
var Discard Recorder = discardRecorder{}
type Origin struct {
Actor Actor
Source Source
IP string
UserAgent string
}
type originContextKey struct{}
func WithOrigin(ctx context.Context, origin Origin) context.Context {
return context.WithValue(ctx, originContextKey{}, origin)
}
func OriginFrom(ctx context.Context) (Origin, bool) {
origin, ok := ctx.Value(originContextKey{}).(Origin)
return origin, ok
}
@@ -0,0 +1,16 @@
package audit
import "time"
type Repository interface {
Append(event Event) (Event, error)
List(filter Filter) (Page, error)
Summary(query SummaryQuery) (Summary, error)
DeleteOlderThan(cutoff time.Time) (int, error)
}
type SettingsRepository interface {
Get() (Settings, error)
SaveRetention(days int) error
SaveLastCleanup(at time.Time, removed int) error
}
+57
View File
@@ -0,0 +1,57 @@
package audit
import "time"
const (
DefaultRetentionDays = 90
ForeverRetentionDays = 0
CleanupHour = 3
KeepRecentHours = 24
)
func RetentionOptions() []int {
return []int{30, 90, 365, ForeverRetentionDays}
}
func ValidateRetentionDays(days int) error {
for _, option := range RetentionOptions() {
if option == days {
return nil
}
}
return ErrInvalidRetention
}
type Settings struct {
RetentionDays int `json:"activity_retention_days"`
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
}
func DefaultSettings() Settings {
return Settings{RetentionDays: DefaultRetentionDays}
}
func (s Settings) CleanupDue(now time.Time) bool {
todayRun := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
if now.Before(todayRun) {
return false
}
return s.LastCleanupAt == nil || s.LastCleanupAt.Before(todayRun)
}
func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
newest := now.Add(-KeepRecentHours * time.Hour)
if cutoff.After(newest) {
return newest
}
return cutoff
}
func RetentionCutoff(now time.Time, retentionDays int) time.Time {
return ClampCutoff(now, now.Add(-time.Duration(retentionDays)*24*time.Hour))
}
type CleanupResult struct {
Removed int
}
@@ -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
}