mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-19 03:03:39 +09:00
feat: Add audit logging (#38189)
Co-authored-by: bircni <bircni@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
co-authored by
bircni
wxiaoguang
parent
4d43445532
commit
da37b7916b
@@ -0,0 +1,209 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Event))
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Action Action `xorm:"INDEX NOT NULL"`
|
||||
ActorID int64 `xorm:"INDEX NOT NULL"`
|
||||
ActorName string
|
||||
ActorCredential string // Credential the actor acted with, e.g. "access-token:<id>", "oauth2-grant:<id>", "gitea-actions:<task id>" or "deploy-key:<key id>".
|
||||
ImpersonatorID int64 `xorm:"INDEX"` // Admin acting as the actor; zero when the actor acted themselves.
|
||||
ImpersonatorName string
|
||||
ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` // Entity ID within ScopeType; zero for system.
|
||||
ScopeType ScopeType `xorm:"INDEX INDEX(scope) NOT NULL"`
|
||||
ScopeName string
|
||||
Origin Origin `xorm:"INDEX NOT NULL"`
|
||||
Message string
|
||||
Metadata string `xorm:"LONGTEXT JSON"`
|
||||
IPAddress string
|
||||
TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"`
|
||||
}
|
||||
|
||||
func (*Event) TableName() string {
|
||||
return "audit_event"
|
||||
}
|
||||
|
||||
func (e *Event) Actor() EntityRef {
|
||||
return EntityRef{Type: ScopeUser, ID: e.ActorID, Name: e.ActorName}
|
||||
}
|
||||
|
||||
// Impersonator returns the admin who acted as the actor, or nil.
|
||||
func (e *Event) Impersonator() *EntityRef {
|
||||
if e.ImpersonatorID == 0 && e.ImpersonatorName == "" {
|
||||
return nil
|
||||
}
|
||||
return &EntityRef{Type: ScopeUser, ID: e.ImpersonatorID, Name: e.ImpersonatorName}
|
||||
}
|
||||
|
||||
func (e *Event) Scope() EntityRef {
|
||||
return EntityRef{Type: e.ScopeType, ID: e.ScopeID, Name: e.ScopeName}
|
||||
}
|
||||
|
||||
func (e *Event) Time() time.Time {
|
||||
return e.TimestampUnix.AsTime()
|
||||
}
|
||||
|
||||
// eventJSON is the nested JSONL export shape.
|
||||
type eventJSON struct {
|
||||
Action Action `json:"action"`
|
||||
Actor EntityRef `json:"actor"`
|
||||
ActorCredential string `json:"actor_credential,omitempty"`
|
||||
Impersonator *EntityRef `json:"impersonator,omitempty"`
|
||||
Scope EntityRef `json:"scope"`
|
||||
Message string `json:"message"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Origin Origin `json:"origin"`
|
||||
}
|
||||
|
||||
func (e *Event) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(eventJSON{
|
||||
Action: e.Action,
|
||||
Actor: e.Actor(),
|
||||
ActorCredential: e.ActorCredential,
|
||||
Impersonator: e.Impersonator(),
|
||||
Scope: e.Scope(),
|
||||
Message: e.Message,
|
||||
Metadata: DecodeMetadata(e.Metadata),
|
||||
Time: e.Time(),
|
||||
IPAddress: e.IPAddress,
|
||||
Origin: e.Origin,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Event) UnmarshalJSON(data []byte) error {
|
||||
var j eventJSON
|
||||
if err := json.Unmarshal(data, &j); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Action = j.Action
|
||||
e.ActorID = j.Actor.ID
|
||||
e.ActorName = j.Actor.Name
|
||||
e.ActorCredential = j.ActorCredential
|
||||
if j.Impersonator != nil {
|
||||
e.ImpersonatorID = j.Impersonator.ID
|
||||
e.ImpersonatorName = j.Impersonator.Name
|
||||
}
|
||||
e.ScopeType = j.Scope.Type
|
||||
e.ScopeID = j.Scope.ID
|
||||
e.ScopeName = j.Scope.Name
|
||||
e.Message = j.Message
|
||||
e.Metadata = EncodeMetadata(j.Metadata)
|
||||
e.IPAddress = j.IPAddress
|
||||
e.Origin = j.Origin
|
||||
e.TimestampUnix = timeutil.TimeStamp(j.Time.Unix())
|
||||
return nil
|
||||
}
|
||||
|
||||
func EncodeMetadata(m map[string]any) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
log.Error("Failed to encode audit metadata: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeMetadata(raw string) map[string]any {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
log.Error("Failed to decode audit metadata: %v", err)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func InsertEvent(ctx context.Context, e *Event) error {
|
||||
return db.Insert(ctx, e)
|
||||
}
|
||||
|
||||
// DeleteOldEvents removes events older than the given duration, keeping everything if it is not positive.
|
||||
func DeleteOldEvents(ctx context.Context, olderThan time.Duration) error {
|
||||
if olderThan <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := db.GetEngine(ctx).Where("timestamp_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Event{})
|
||||
return err
|
||||
}
|
||||
|
||||
type EventSort string
|
||||
|
||||
const (
|
||||
SortTimestampAsc EventSort = "timestamp_asc"
|
||||
SortTimestampDesc EventSort = "timestamp_desc"
|
||||
)
|
||||
|
||||
type EventSearchOptions struct {
|
||||
db.ListOptions
|
||||
Action Action
|
||||
// ActionPrefix filters an action family. It is mutually exclusive with Action.
|
||||
ActionPrefix Action
|
||||
ActorID int64
|
||||
ScopeType ScopeType
|
||||
ScopeID int64
|
||||
Origin Origin
|
||||
Sort EventSort
|
||||
}
|
||||
|
||||
func (opts *EventSearchOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if opts.Action != "" {
|
||||
cond = cond.And(builder.Eq{"action": opts.Action})
|
||||
} else if opts.ActionPrefix != "" {
|
||||
cond = cond.And(builder.Like{"action", string(opts.ActionPrefix) + ":%"})
|
||||
}
|
||||
if opts.ActorID != 0 {
|
||||
// an impersonated event belongs to both the actor and the admin behind it
|
||||
cond = cond.And(builder.Eq{"actor_id": opts.ActorID}.Or(builder.Eq{"impersonator_id": opts.ActorID}))
|
||||
}
|
||||
// applied independently so a missing scope ID narrows the query instead of
|
||||
// silently widening it to every scope
|
||||
if opts.ScopeType != "" {
|
||||
cond = cond.And(builder.Eq{"scope_type": opts.ScopeType})
|
||||
}
|
||||
if opts.ScopeID != 0 {
|
||||
cond = cond.And(builder.Eq{"scope_id": opts.ScopeID})
|
||||
}
|
||||
if opts.Origin != "" {
|
||||
cond = cond.And(builder.Eq{"origin": opts.Origin})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *EventSearchOptions) ToOrders() string {
|
||||
if opts.Sort == SortTimestampAsc {
|
||||
return "timestamp_unix ASC, id ASC"
|
||||
}
|
||||
return "timestamp_unix DESC, id DESC"
|
||||
}
|
||||
|
||||
func FindEvents(ctx context.Context, opts *EventSearchOptions) ([]*Event, int64, error) {
|
||||
return db.FindAndCount[Event](ctx, opts)
|
||||
}
|
||||
Reference in New Issue
Block a user