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