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
}