mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-19 11:13:41 +09:00
feat: Add audit logging (#38189)
Co-authored-by: bircni <bircni@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
co-authored by
bircni
wxiaoguang
parent
4d43445532
commit
da37b7916b
@@ -0,0 +1,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"))
|
||||
}
|
||||
Reference in New Issue
Block a user