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:
bircni
2026-09-12 10:15:23 +02:00
committed by GitHub
parent 4d43445532
commit da37b7916b
136 changed files with 3864 additions and 209 deletions
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
user_model "gitea.dev/models/user"
"gitea.dev/modules/web/middleware"
)
type doerContextKeyType struct{}
var doerContextKey doerContextKeyType
type impersonatorContextKeyType struct{}
var impersonatorContextKey impersonatorContextKeyType
// WithImpersonator returns a context that records audit events as performed by
// the doer on behalf of the given admin.
func WithImpersonator(ctx context.Context, impersonator *user_model.User) context.Context {
return context.WithValue(ctx, impersonatorContextKey, impersonator)
}
// WithDoer returns a context that records audit events as the given user.
//
// Web and API requests need this only in unusual cases: the signed-in user is
// already published to the request data store by routers/common.AuthShared and
// is picked up automatically. Use it at entry points that have no signed-in
// user - the CLI, cron tasks, authentication source syncs and git hooks - before
// calling into services that record audit events themselves.
func WithDoer(ctx context.Context, doer *user_model.User) context.Context {
return context.WithValue(ctx, doerContextKey, doer)
}
// doerFromContext resolves the actor of an audit event: an explicit WithDoer
// value wins over the signed-in user of the surrounding request. Returns nil
// when neither is available, which Record turns into an unknown actor.
func doerFromContext(ctx context.Context) *user_model.User {
if doer, ok := ctx.Value(doerContextKey).(*user_model.User); ok && doer != nil {
return doer
}
if data := middleware.GetContextData(ctx); data != nil {
if doer, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
return doer
}
}
return nil
}
// credentialFromContext returns the credential the surrounding request
// authenticated with. It is dropped when the event is recorded for someone
// other than the signed-in user, so an explicit actor is never tied to a
// credential that is not theirs.
func credentialFromContext(ctx context.Context, doer *user_model.User) string {
data := middleware.GetContextData(ctx)
if data == nil {
return ""
}
signedUser, _ := data[middleware.ContextDataKeySignedUser].(*user_model.User)
if signedUser == nil || signedUser.ID != doer.ID {
return ""
}
credential, _ := data[middleware.ContextDataKeyAuthCredential].(string)
return credential
}
// ImpersonatorFromContext resolves the admin acting as the doer, so an event
// recorded during an impersonated session cannot be pinned on the impersonated
// user alone. Returns nil for ordinary sessions.
func ImpersonatorFromContext(ctx context.Context) *user_model.User {
if impersonator, ok := ctx.Value(impersonatorContextKey).(*user_model.User); ok && impersonator != nil {
return impersonator
}
if data := middleware.GetContextData(ctx); data != nil {
if impersonator, ok := data[middleware.ContextDataKeyImpersonator].(*user_model.User); ok {
return impersonator
}
}
return nil
}
+279
View File
@@ -0,0 +1,279 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"testing"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/web/middleware"
"github.com/stretchr/testify/assert"
)
func BenchmarkRecordDisabled(b *testing.B) {
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDisabled)()
ctx := context.Background()
u := &user_model.User{ID: 1, Name: "user"}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
RecordAs(ctx, u, audit_model.UserPassword, u)
}
}
func BenchmarkBuildEvent(b *testing.B) {
params := RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "actor"},
Scope: audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"},
Metadata: map[string]any{
"remote_address": "https://example.com/repo.git",
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = buildEvent(context.Background(), params)
}
}
// newRequestContext mimics what routers/common.AuthShared publishes for a
// signed-in request.
func newRequestContext(t *testing.T, signedIn *user_model.User) context.Context {
t.Helper()
rc := reqctx.NewRequestContextForTest(t)
rc.GetData()[middleware.ContextDataKeySignedUser] = signedIn
return rc
}
func TestBuildEvent(t *testing.T) {
doer := &user_model.User{ID: 2, Name: "Doer"}
u := &user_model.User{ID: 1, Name: "TestUser"}
t.Run("MessageFromTemplate", func(t *testing.T) {
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserCreate,
Actor: actorRef(doer),
Scope: ScopeFromUser(u),
})
assert.Equal(t, audit_model.UserCreate, e.Action)
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 2, Name: "Doer"}, e.Actor())
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}, e.Scope())
assert.Equal(t, "Created user TestUser.", e.Message)
})
t.Run("MetadataFillsPlaceholders", func(t *testing.T) {
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: actorRef(doer),
Scope: ScopeFromRepository(r),
Metadata: metaPairs(
"mirror_id", m.ID,
"remote_address", m.RemoteAddress,
),
})
assert.Equal(t, "TestUser/TestRepo", e.ScopeName)
assert.Equal(t, "Added push mirror to git@example.com:repo.git for repository TestUser/TestRepo.", e.Message)
assert.InDelta(t, float64(m.ID), audit_model.DecodeMetadata(e.Metadata)["mirror_id"], 0)
})
t.Run("StatusChangesIncludeTheirNewValue", func(t *testing.T) {
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserRestricted,
Actor: actorRef(doer),
Scope: ScopeFromUser(u),
Metadata: metaPairs("restricted", true),
})
assert.Equal(t, "Changed restricted status of user TestUser to true.", e.Message)
})
t.Run("SystemActorNamesTaskOrKey", func(t *testing.T) {
actions := user_model.NewActionsUserWithTaskID(42)
e := buildEvent(context.Background(), RecordParams{
Action: audit_model.UserCreate,
Actor: actorRef(actions),
ActorCredential: actorCredential(context.Background(), actions),
Scope: ScopeFromUser(u),
})
assert.Equal(t, user_model.ActionsUserID, e.ActorID)
assert.Equal(t, "gitea-actions:42", e.ActorCredential)
key := user_model.NewDeployKeyUserWithKeyID(7)
assert.Equal(t, "deploy-key:7", actorCredential(context.Background(), key))
assert.Empty(t, actorCredential(context.Background(), doer))
})
t.Run("CredentialFromRequest", func(t *testing.T) {
ctx := newRequestContext(t, doer)
middleware.GetContextData(ctx)[middleware.ContextDataKeyAuthCredential] = "access-token:9"
assert.Equal(t, "access-token:9", actorCredential(ctx, doer))
// an event recorded for someone other than the signed-in user is not
// tied to the credential of that request
assert.Empty(t, actorCredential(ctx, u))
})
t.Run("RequestInfo", func(t *testing.T) {
params := RecordParams{Action: audit_model.UserCreate, Actor: actorRef(doer), Scope: ScopeFromUser(u)}
e := buildEvent(context.Background(), params)
assert.Empty(t, e.IPAddress)
assert.Equal(t, audit_model.OriginSystem, e.Origin)
cliCtx := WithOrigin(context.Background(), audit_model.OriginCLI)
assert.Equal(t, audit_model.OriginCLI, buildEvent(cliCtx, params).Origin)
apiCtx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(apiCtx, audit_model.OriginAPI, "127.0.0.1")
e = buildEvent(apiCtx, params)
assert.Equal(t, "127.0.0.1", e.IPAddress)
assert.Equal(t, audit_model.OriginAPI, e.Origin)
// an explicit origin wins over the one of the surrounding request
systemAPIContext := WithOrigin(apiCtx, audit_model.OriginSystem)
assert.Equal(t, audit_model.OriginSystem, buildEvent(systemAPIContext, params).Origin)
})
}
func TestEntityRefDisplay(t *testing.T) {
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}
assert.Equal(t, "TestUser", ref.DisplayName())
assert.Equal(t, "/TestUser", ref.HomeLink())
assert.True(t, ref.HasLink())
sys := ScopeSystem()
assert.Equal(t, "System", sys.DisplayName())
assert.Empty(t, sys.HomeLink())
assert.False(t, sys.HasLink())
// a scope whose entity was deleted keeps its ID but has no name to link to
deleted := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3}
assert.Empty(t, deleted.DisplayName())
assert.False(t, deleted.HasLink())
repo := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3, Name: "Test User/Test Repo"}
assert.Equal(t, "/Test%20User/Test%20Repo", repo.HomeLink())
assert.True(t, repo.HasLink())
}
func TestEncodeDecodeMetadata(t *testing.T) {
raw := audit_model.EncodeMetadata(metaPairs("repo_id", int64(42), "repo", "o/r"))
decoded := audit_model.DecodeMetadata(raw)
assert.InDelta(t, 42.0, decoded["repo_id"], 0) // json numbers decode as float64
assert.Equal(t, "o/r", decoded["repo"])
}
func TestDoerFromContext(t *testing.T) {
doer := &user_model.User{ID: 2, Name: "Doer"}
signedIn := &user_model.User{ID: 3, Name: "SignedIn"}
t.Run("NoActor", func(t *testing.T) {
assert.Nil(t, doerFromContext(context.Background()))
})
t.Run("WithDoer", func(t *testing.T) {
assert.Equal(t, doer, doerFromContext(WithDoer(context.Background(), doer)))
})
t.Run("SignedInUserOfRequest", func(t *testing.T) {
ctx := newRequestContext(t, signedIn)
assert.Equal(t, signedIn, doerFromContext(ctx))
})
t.Run("WithDoerWinsOverSignedInUser", func(t *testing.T) {
ctx := WithDoer(newRequestContext(t, signedIn), doer)
assert.Equal(t, doer, doerFromContext(ctx))
})
}
// An impersonated session must never pin an event on the impersonated user
// alone, otherwise an admin could act in someone else's name untraceably.
func TestImpersonatorRef(t *testing.T) {
admin := &user_model.User{ID: 1, Name: "Admin"}
impersonated := &user_model.User{ID: 2, Name: "Impersonated"}
rc := reqctx.NewRequestContextForTest(t)
rc.GetData()[middleware.ContextDataKeySignedUser] = impersonated
rc.GetData()[middleware.ContextDataKeyImpersonator] = admin
assert.Equal(t, admin, ImpersonatorFromContext(rc))
e := buildEvent(rc, RecordParams{
Action: audit_model.UserPassword,
Actor: actorRef(doerFromContext(rc)),
Impersonator: impersonatorRef(ImpersonatorFromContext(rc), doerFromContext(rc)),
Scope: ScopeFromUser(impersonated),
})
assert.Equal(t, int64(2), e.ActorID)
assert.Equal(t, &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Admin"}, e.Impersonator())
// an admin acting as themselves is not an impersonation
assert.Nil(t, impersonatorRef(admin, admin))
assert.Nil(t, impersonatorRef(nil, impersonated))
}
// An unresolvable actor must still produce an event, so a missing entry point
// never silently drops security relevant records.
func TestActorRefWithoutDoer(t *testing.T) {
ref := actorRef(nil)
assert.Equal(t, "Unknown", ref.DisplayName())
assert.False(t, ref.HasLink())
}
func TestRenderMessage(t *testing.T) {
actor := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Actor"}
scope := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"}
t.Run("EveryActionHasATemplate", func(t *testing.T) {
for _, action := range audit_model.AllActions() {
tmpl, ok := audit_model.MessageTemplate(action)
assert.True(t, ok, "action %q has no message template", action)
assert.NotEmpty(t, tmpl, "action %q has empty message template", action)
}
})
t.Run("ReservedPlaceholders", func(t *testing.T) {
assert.Equal(t,
"User Actor started impersonating user owner/repo.",
renderMessage(audit_model.UserImpersonation, actor, scope, nil),
)
})
t.Run("NonStringMetadata", func(t *testing.T) {
assert.Equal(t,
"Removed external login from authentication source 7 for user owner/repo.",
renderMessage(audit_model.UserExternalLoginRemove, actor, scope, map[string]any{"auth_source_id": int64(7)}),
)
})
t.Run("MissingMetadataKeepsTheKey", func(t *testing.T) {
assert.Equal(t,
"Added deploy key deploy_key for repository owner/repo.",
renderMessage(audit_model.RepositoryDeployKeyAdd, actor, scope, nil),
)
})
t.Run("UnknownActionFallsBackToItsName", func(t *testing.T) {
assert.Equal(t, "not:an:action", renderMessage(audit_model.Action("not:an:action"), actor, scope, nil))
})
}
func TestActionFilters(t *testing.T) {
assert.True(t, audit_model.IsActionFilter("user:impersonation"))
assert.True(t, audit_model.IsActionFilter(audit_model.UserImpersonation))
assert.False(t, audit_model.IsActionFilter("user:unknown"))
}
+165
View File
@@ -0,0 +1,165 @@
// 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
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"io"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/json"
)
// WriteEventsAsJSON writes one JSON object per line.
func WriteEventsAsJSON(w io.Writer, events []*audit_model.Event) error {
for _, event := range events {
b, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"strings"
"testing"
"time"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
)
func TestWriteEventsAsJSON(t *testing.T) {
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
doer := &user_model.User{ID: 2, Name: "Doer"}
ctx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
e := buildEvent(ctx, RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: actorRef(doer),
Scope: ScopeFromRepository(r),
Metadata: metaPairs(
"mirror_id", m.ID,
"remote_address", m.RemoteAddress,
),
})
e.TimestampUnix = timeutil.TimeStamp(time.Time{}.Unix())
sb := strings.Builder{}
assert.NoError(t, WriteEventsAsJSON(&sb, []*audit_model.Event{e, e}))
out := sb.String()
assert.Equal(t, 2, strings.Count(out, "\n"))
assert.Contains(t, out, `"action":"repository:mirror:push:add"`)
assert.Contains(t, out, `"name":"Doer"`)
assert.Contains(t, out, `"metadata"`)
assert.Contains(t, out, `"remote_address":"git@example.com:repo.git"`)
assert.Contains(t, out, `"ip_address":"127.0.0.1"`)
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
"gitea.dev/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"fmt"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/log"
)
// Reserved placeholders, filled from the event itself rather than from metadata.
const (
placeholderScope = "scope"
placeholderActor = "actor"
)
// renderMessage fills the action's template from the event's scope, actor and
// metadata. A missing template or an unresolved placeholder is logged and
// rendered as the bare key: audit recording must never fail the request that
// triggered it, and a partial message is more useful than none.
func renderMessage(action audit_model.Action, actor, scope audit_model.EntityRef, metadata map[string]any) string {
tmpl, ok := audit_model.MessageTemplate(action)
if !ok {
log.Error("audit: no message template for action %q", action)
return string(action)
}
var sb strings.Builder
rest := tmpl
for {
start := strings.IndexByte(rest, '{')
if start < 0 {
break
}
end := strings.IndexByte(rest[start:], '}')
if end < 0 {
break
}
end += start
key := rest[start+1 : end]
sb.WriteString(rest[:start])
sb.WriteString(resolvePlaceholder(action, key, actor, scope, metadata))
rest = rest[end+1:]
}
sb.WriteString(rest)
return sb.String()
}
func resolvePlaceholder(action audit_model.Action, key string, actor, scope audit_model.EntityRef, metadata map[string]any) string {
switch key {
case placeholderScope:
return scope.DisplayName()
case placeholderActor:
return actor.DisplayName()
}
if v, ok := metadata[key]; ok {
return fmt.Sprint(v)
}
log.Error("audit: action %q has no metadata for placeholder %q", action, key)
return key
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
notify_service "gitea.dev/services/notify"
)
func init() {
notify_service.RegisterNotifier(new(auditNotifier))
}
type auditNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = new(auditNotifier)
func (n *auditNotifier) CreateRepository(ctx context.Context, doer, _ *user_model.User, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryCreate, repo)
}
func (n *auditNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryCreateFork, repo, "base_repo", oldRepo.FullName())
}
func (n *auditNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
RecordAs(ctx, doer, audit_model.RepositoryName, repo, "previous_name", oldRepoName)
}
func (n *auditNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
RecordAs(ctx, doer, audit_model.RepositoryTransferFinish, repo, "old_owner", oldOwnerName, "new_owner", repo.OwnerName)
}
func (n *auditNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryTransferStart, repo, "new_owner", newOwner.Name)
}
func (n *auditNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
Record(ctx, audit_model.RepositoryBranchDefault, repo, "default_branch", repo.DefaultBranch)
}
func issueLabel(issue *issues_model.Issue) string {
return fmt.Sprintf("#%d", issue.Index)
}
func loadIssueRepo(ctx context.Context, issue *issues_model.Issue) *repo_model.Repository {
if issue.Repo == nil {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("audit: LoadRepo for issue %d: %v", issue.ID, err)
return nil
}
}
return issue.Repo
}
func issueOrPR(issue *issues_model.Issue, issueAction, prAction audit_model.Action) (audit_model.Action, string) {
if issue.IsPull {
return prAction, "pull_request"
}
return issueAction, "issue"
}
// issueMeta keeps the label, ID and title keys of every issue and pull request
// event consistent; the ID is always the issue row ID the label refers to.
func issueMeta(issue *issues_model.Issue, key string) []any {
return []any{key, issueLabel(issue), key + "_id", issue.ID, "title", issue.Title}
}
func (n *auditNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) {
repo := loadIssueRepo(ctx, issue)
if repo == nil {
return
}
RecordAs(ctx, issue.Poster, audit_model.IssueCreate, repo, issueMeta(issue, "issue")...)
}
func (n *auditNotifier) DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
repo := loadIssueRepo(ctx, issue)
if repo == nil {
return
}
action, key := issueOrPR(issue, audit_model.IssueDelete, audit_model.PullRequestDelete)
RecordAs(ctx, doer, action, repo, issueMeta(issue, key)...)
}
func (n *auditNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, _ []*user_model.User) {
if pr.Issue == nil {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
return
}
}
repo := loadIssueRepo(ctx, pr.Issue)
if repo == nil {
return
}
RecordAs(ctx, pr.Issue.Poster, audit_model.PullRequestCreate, repo, issueMeta(pr.Issue, "pull_request")...)
}
func (n *auditNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if pr.Issue == nil {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
return
}
}
repo := loadIssueRepo(ctx, pr.Issue)
if repo == nil {
return
}
RecordAs(ctx, doer, audit_model.PullRequestMerge, repo, issueMeta(pr.Issue, "pull_request")...)
}
func (n *auditNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User) {
action, key := issueOrPR(issue, audit_model.IssueCommentCreate, audit_model.PullRequestCommentCreate)
RecordAs(ctx, doer, action, repo, key, issueLabel(issue), "comment_id", comment.ID)
}
func (n *auditNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) {
if comment.Issue == nil {
if err := comment.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for comment %d: %v", comment.ID, err)
return
}
}
repo := loadIssueRepo(ctx, comment.Issue)
if repo == nil {
return
}
action, key := issueOrPR(comment.Issue, audit_model.IssueCommentDelete, audit_model.PullRequestCommentDelete)
RecordAs(ctx, doer, action, repo, key, issueLabel(comment.Issue), "comment_id", comment.ID)
}
func (n *auditNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
RecordAs(ctx, doer, audit_model.WikiPageCreate, repo, "page", page)
}
func (n *auditNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
RecordAs(ctx, doer, audit_model.WikiPageUpdate, repo, "page", page)
}
func (n *auditNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) {
RecordAs(ctx, doer, audit_model.WikiPageDelete, repo, "page", page)
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestAuditNotifier(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
doer := &user_model.User{ID: 2, Name: "doer"}
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
ctx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
notifier := new(auditNotifier)
issue := &issues_model.Issue{ID: 10, Index: 5, Title: "Issue title", Poster: doer, Repo: repo}
notifier.NewIssue(ctx, issue, nil)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.IssueCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
pr := &issues_model.PullRequest{ID: 11, Issue: &issues_model.Issue{ID: 12, Index: 6, Title: "PR title", Poster: doer, Repo: repo, IsPull: true}}
notifier.NewPullRequest(ctx, pr, nil)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.PullRequestCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.NewWikiPage(ctx, doer, repo, "Home", "")
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.WikiPageCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.CreateRepository(ctx, doer, doer, repo)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryCreate,
ActorID: doer.ID,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.TransferRepository(ctx, doer, repo, "previous_owner")
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryTransferFinish,
ActorID: doer.ID,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Message: "Transferred repository owner/repo from previous_owner to owner.",
})
notifier.ChangeDefaultBranch(ctx, repo)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryBranchDefault,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
})
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// actorRef builds the actor reference of an event. An unresolvable actor means
// an entry point neither runs inside an authenticated request nor called
// WithDoer; record the event with an "Unknown" actor rather than dropping it,
// and log so the missing entry point is visible.
func actorRef(doer *user_model.User) audit_model.EntityRef {
if doer == nil {
log.Error("audit: no actor in context, recording event as unknown actor")
return audit_model.EntityRef{Type: audit_model.ScopeUser, Name: "Unknown"}
}
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: doer.ID, Name: doer.Name}
}
// actorCredential names what the actor acted with: the task or key behind a
// system user, otherwise the token the surrounding request authenticated with.
// An incident then traces one credential across every event it produced,
// instead of stopping at the account that owns it.
func actorCredential(ctx context.Context, doer *user_model.User) string {
if doer == nil {
return ""
}
if doer.ExtDoerData != nil {
return doer.ExtDoerData.EncodeToString()
}
return credentialFromContext(ctx, doer)
}
// impersonatorRef names the admin behind an impersonated session. It is dropped
// when the actor is the admin themselves, so events an admin performs before
// entering or after leaving an impersonation are not marked as impersonated.
func impersonatorRef(impersonator, doer *user_model.User) *audit_model.EntityRef {
if impersonator == nil || (doer != nil && impersonator.ID == doer.ID) {
return nil
}
return &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: impersonator.ID, Name: impersonator.Name}
}
func ScopeFromUser(u *user_model.User) audit_model.EntityRef {
if u == nil {
return audit_model.EntityRef{}
}
if u.IsOrganization() {
return audit_model.EntityRef{Type: audit_model.ScopeOrganization, ID: u.ID, Name: u.Name}
}
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: u.ID, Name: u.Name}
}
// ScopeFromUserID resolves the scope of a user known only by ID, for call sites
// that would otherwise load the user solely to name it. It costs nothing while
// audit logging is off, and a failed lookup still yields a usable scope so the
// event is never dropped.
func ScopeFromUserID(ctx context.Context, id int64) audit_model.EntityRef {
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: id}
if !setting.AuditRecordEnabled() {
return ref
}
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
log.Error("audit: GetUserByID(%d): %v", id, err)
return ref
}
return ScopeFromUser(u)
}
func ScopeFromRepository(repo *repository_model.Repository) audit_model.EntityRef {
if repo == nil {
return audit_model.EntityRef{}
}
return audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: repo.ID, Name: repo.FullName()}
}
func ScopeSystem() audit_model.EntityRef {
return audit_model.EntityRef{Type: audit_model.ScopeSystem}
}
// scopeRef derives an EntityRef from the affected entity passed to Record.
// Supported types: *user_model.User, *repository_model.Repository, EntityRef,
// or nil for an instance-wide event.
func scopeRef(scope any) audit_model.EntityRef {
switch s := scope.(type) {
case nil:
return ScopeSystem()
case audit_model.EntityRef:
return s
case *user_model.User:
return ScopeFromUser(s)
case *repository_model.Repository:
return ScopeFromRepository(s)
default:
// Audit recording must never crash the request that triggered it; record
// a system-scoped event instead of panicking on an unexpected type.
log.Error("audit: unsupported scope type %T; recording as system scope", scope)
return ScopeSystem()
}
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
)
// ScopedActions holds the action variants for a resource that can be owned by a
// repository, organization, user or the instance itself. RecordScoped selects
// the matching one based on the owner/repo passed at the call site.
type ScopedActions struct {
Repo audit_model.Action
Org audit_model.Action
User audit_model.Action
System audit_model.Action
}
var (
SecretAdd = ScopedActions{
Repo: audit_model.RepositorySecretAdd,
Org: audit_model.OrganizationSecretAdd,
User: audit_model.UserSecretAdd,
}
SecretUpdate = ScopedActions{
Repo: audit_model.RepositorySecretUpdate,
Org: audit_model.OrganizationSecretUpdate,
User: audit_model.UserSecretUpdate,
}
SecretRemove = ScopedActions{
Repo: audit_model.RepositorySecretRemove,
Org: audit_model.OrganizationSecretRemove,
User: audit_model.UserSecretRemove,
}
OAuth2ApplicationAdd = ScopedActions{
User: audit_model.UserOAuth2ApplicationAdd,
Org: audit_model.OrganizationOAuth2ApplicationAdd,
System: audit_model.SystemOAuth2ApplicationAdd,
}
OAuth2ApplicationUpdate = ScopedActions{
User: audit_model.UserOAuth2ApplicationUpdate,
Org: audit_model.OrganizationOAuth2ApplicationUpdate,
System: audit_model.SystemOAuth2ApplicationUpdate,
}
OAuth2ApplicationSecret = ScopedActions{
User: audit_model.UserOAuth2ApplicationSecret,
Org: audit_model.OrganizationOAuth2ApplicationSecret,
System: audit_model.SystemOAuth2ApplicationSecret,
}
OAuth2ApplicationRemove = ScopedActions{
User: audit_model.UserOAuth2ApplicationRemove,
Org: audit_model.OrganizationOAuth2ApplicationRemove,
System: audit_model.SystemOAuth2ApplicationRemove,
}
OAuth2ApplicationRevoke = ScopedActions{
User: audit_model.UserOAuth2ApplicationRevoke,
}
WebhookAdd = ScopedActions{
Repo: audit_model.RepositoryWebhookAdd,
Org: audit_model.OrganizationWebhookAdd,
User: audit_model.UserWebhookAdd,
System: audit_model.SystemWebhookAdd,
}
WebhookUpdate = ScopedActions{
Repo: audit_model.RepositoryWebhookUpdate,
Org: audit_model.OrganizationWebhookUpdate,
User: audit_model.UserWebhookUpdate,
System: audit_model.SystemWebhookUpdate,
}
WebhookRemove = ScopedActions{
Repo: audit_model.RepositoryWebhookRemove,
Org: audit_model.OrganizationWebhookRemove,
User: audit_model.UserWebhookRemove,
System: audit_model.SystemWebhookRemove,
}
)
// resolveScope maps an (owner, repo) pair to the scoped action and audit scope.
// The rules cover every multi-scope resource (secrets, OAuth2 apps, webhooks):
// a repo wins when set, a nil owner means the instance, otherwise the owner's
// kind decides.
func resolveScope(actions ScopedActions, owner *user_model.User, repo *repository_model.Repository) (audit_model.Action, audit_model.EntityRef) {
switch {
case repo != nil:
return actions.Repo, ScopeFromRepository(repo)
case owner == nil:
return actions.System, ScopeSystem()
case owner.IsOrganization():
return actions.Org, ScopeFromUser(owner)
default:
return actions.User, ScopeFromUser(owner)
}
}
// RecordScoped records an audit event for a resource owned by a repository (repo
// set), organization, user, or the instance (owner nil, repo nil). It picks the
// scoped action and scope; each variant carries its own message template, so the
// wording follows automatically. Metadata is supplied as alternating
// string-key/value pairs, like Record.
func RecordScoped(ctx context.Context, owner *user_model.User, repo *repository_model.Repository, actions ScopedActions, metadata ...any) {
action, scope := resolveScope(actions, owner, repo)
if action == "" {
log.Error("audit: no action configured for scope type %s", scope.Type)
return
}
doer := doerFromContext(ctx)
writeEvent(ctx, RecordParams{
Action: action,
Actor: actorRef(doer),
ActorCredential: actorCredential(ctx, doer),
Scope: scope,
Metadata: metaPairs(metadata...),
})
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestResolveScope(t *testing.T) {
actions := WebhookAdd
org := &user_model.User{ID: 10, Name: "MyOrg", Type: user_model.UserTypeOrganization}
usr := &user_model.User{ID: 11, Name: "MyUser", Type: user_model.UserTypeIndividual}
repo := &repository_model.Repository{ID: 12, Name: "repo", OwnerName: "MyOrg"}
t.Run("repo wins over owner", func(t *testing.T) {
action, scope := resolveScope(actions, org, repo)
assert.Equal(t, audit_model.RepositoryWebhookAdd, action)
assert.Equal(t, audit_model.ScopeRepository, scope.Type)
assert.Equal(t, "MyOrg/repo", scope.Name)
})
t.Run("organization owner", func(t *testing.T) {
action, scope := resolveScope(actions, org, nil)
assert.Equal(t, audit_model.OrganizationWebhookAdd, action)
assert.Equal(t, audit_model.ScopeOrganization, scope.Type)
assert.Equal(t, "MyOrg", scope.Name)
})
t.Run("user owner", func(t *testing.T) {
action, scope := resolveScope(actions, usr, nil)
assert.Equal(t, audit_model.UserWebhookAdd, action)
assert.Equal(t, audit_model.ScopeUser, scope.Type)
assert.Equal(t, "MyUser", scope.Name)
})
t.Run("system when no owner and no repo", func(t *testing.T) {
action, scope := resolveScope(actions, nil, nil)
assert.Equal(t, audit_model.SystemWebhookAdd, action)
assert.Equal(t, audit_model.ScopeSystem, scope.Type)
})
}
// Audit recording must never crash the request that triggered it.
func TestRecordHelpersNeverPanic(t *testing.T) {
t.Run("metaPairs skips non-string keys", func(t *testing.T) {
var m map[string]any
assert.NotPanics(t, func() {
m = metaPairs("ok", 1, 42 /* bad key */, "value", "second", 2)
})
assert.Equal(t, 1, m["ok"])
assert.Equal(t, 2, m["second"])
assert.Len(t, m, 2) // the pair with the non-string key is dropped
})
t.Run("scopeRef falls back to system on unsupported type", func(t *testing.T) {
var ref audit_model.EntityRef
assert.NotPanics(t, func() {
ref = scopeRef(struct{ Foo string }{Foo: "bar"})
})
assert.Equal(t, audit_model.ScopeSystem, ref.Type)
})
}