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
|
||||
}
|
||||
Reference in new issue
Block a user