Files
gitea/services/audit/event.go
bircni da37b7916b feat: Add audit logging (#38189)
Co-authored-by: bircni <bircni@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-09-12 08:15:23 +00:00

166 lines
5.6 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"time"
audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
)
// RecordParams describes an audit event. Callers (or domain-specific helpers)
// supply metadata; the message is rendered from the action's template.
type RecordParams struct {
Action audit_model.Action
Actor audit_model.EntityRef
ActorCredential string
Impersonator *audit_model.EntityRef
Scope audit_model.EntityRef
Metadata map[string]any
}
type originContextKeyType struct{}
var originContextKey originContextKeyType
type requestInfoContextKeyType struct{}
var requestInfoContextKey requestInfoContextKeyType
// requestInfo is what an audit event needs to know about the request it was
// recorded for. The routers publish it, so services don't have to reach for the
// request themselves.
type requestInfo struct {
origin audit_model.Origin
ipAddress string
}
// WithOrigin returns a context that records audit events with the given origin.
// It is for entry points that serve no request, eg: the CLI or a cron task.
func WithOrigin(ctx context.Context, origin audit_model.Origin) context.Context {
return context.WithValue(ctx, originContextKey, origin)
}
// SetRequestInfo attributes the audit events recorded while serving a request
// to the given origin and client address.
func SetRequestInfo(store reqctx.RequestDataStore, origin audit_model.Origin, ipAddress string) {
store.SetContextValue(requestInfoContextKey, &requestInfo{origin: origin, ipAddress: ipAddress})
}
func requestInfoFromContext(ctx context.Context) *requestInfo {
info, _ := ctx.Value(requestInfoContextKey).(*requestInfo)
return info
}
func buildEvent(ctx context.Context, params RecordParams) *audit_model.Event {
e := &audit_model.Event{
Action: params.Action,
ActorID: params.Actor.ID,
ActorName: params.Actor.DisplayName(),
ActorCredential: params.ActorCredential,
ScopeType: params.Scope.Type,
ScopeID: params.Scope.ID,
ScopeName: params.Scope.DisplayName(),
Message: renderMessage(params.Action, params.Actor, params.Scope, params.Metadata),
Metadata: audit_model.EncodeMetadata(params.Metadata),
IPAddress: getIPAddress(ctx),
Origin: getOrigin(ctx),
TimestampUnix: timeutil.TimeStamp(time.Now().Unix()),
}
if params.Impersonator != nil {
e.ImpersonatorID = params.Impersonator.ID
e.ImpersonatorName = params.Impersonator.DisplayName()
}
return e
}
func getIPAddress(ctx context.Context) string {
if info := requestInfoFromContext(ctx); info != nil {
return info.ipAddress
}
return ""
}
func getOrigin(ctx context.Context) audit_model.Origin {
if origin, ok := ctx.Value(originContextKey).(audit_model.Origin); ok && origin != "" {
return origin
}
if info := requestInfoFromContext(ctx); info != nil && info.origin != "" {
return info.origin
}
return audit_model.OriginSystem
}
// Record writes an audit event for an action against a scope entity. The actor
// is the signed-in user of the surrounding request, or whoever audit.WithDoer
// named for a background context.
//
// The scope is the affected entity and may be a *user.User,
// *organization.Organization, *repo.Repository, an EntityRef, or nil for an
// instance-wide/system event. Metadata is supplied as alternating
// string-key/value pairs and fills the placeholders of the action's message
// template, so every key a template names must be passed here.
//
// audit.Record(ctx, audit_model.RepositoryArchive, repo)
// audit.Record(ctx, audit_model.RepositoryDeployKeyAdd, repo, "deploy_key", key.Name)
func Record(ctx context.Context, action audit_model.Action, scope any, metadata ...any) {
RecordAs(ctx, doerFromContext(ctx), action, scope, metadata...)
}
// RecordAs is Record with an explicit actor, for the few call sites where the
// acting user is not the one the context resolves to.
func RecordAs(ctx context.Context, doer *user_model.User, action audit_model.Action, scope any, metadata ...any) {
writeEvent(ctx, RecordParams{
Action: action,
Actor: actorRef(doer),
ActorCredential: actorCredential(ctx, doer),
Impersonator: impersonatorRef(ImpersonatorFromContext(ctx), doer),
Scope: scopeRef(scope),
Metadata: metaPairs(metadata...),
})
}
// writeEvent persists an audit event when audit logging is enabled.
func writeEvent(ctx context.Context, params RecordParams) {
if !setting.AuditRecordEnabled() {
return
}
e := buildEvent(ctx, params)
if err := audit_model.InsertEvent(ctx, e); err != nil {
log.Error("Error writing audit event action=%s actor=%s scope=%s/%d to database: %v", e.Action, e.ActorName, e.ScopeType, e.ScopeID, err)
}
}
func FindEvents(ctx context.Context, opts *audit_model.EventSearchOptions) ([]*audit_model.Event, int64, error) {
return audit_model.FindEvents(ctx, opts)
}
// metaPairs builds caller-defined metadata from alternating string-key/value
// pairs. Keys should be stable for log parsers. A non-string key is skipped and
// logged rather than panicking: audit recording must never crash the request
// that triggered it.
func metaPairs(pairs ...any) map[string]any {
if len(pairs) == 0 {
return nil
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
log.Error("audit: metadata key must be string, got %T; skipping pair", pairs[i])
continue
}
m[key] = pairs[i+1]
}
return m
}