mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-14 19:22:09 +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:
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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...),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user