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
+85 -43
View File
@@ -8,10 +8,12 @@ import (
"errors"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
// ReplacePrimaryEmailAddress replaces the user's primary email address with the given email address.
@@ -30,7 +32,8 @@ func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailSt
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
var newEmail *user_model.EmailAddress
if err := db.WithTx(ctx, func(ctx context.Context) error {
// Check if address exists already
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
if err != nil && !errors.Is(err, util.ErrNotExist) {
@@ -53,65 +56,104 @@ func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailSt
}
// Insert new primary address
if _, err := user_model.InsertEmailAddress(ctx, &user_model.EmailAddress{
newEmail = &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: true,
IsPrimary: true,
}); err != nil {
}
if _, err := user_model.InsertEmailAddress(ctx, newEmail); err != nil {
return err
}
u.Email = emailStr
return user_model.UpdateUserCols(ctx, u, "email")
})
}
}); err != nil {
return err
}
func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {
for _, emailStr := range emails {
if err := user_model.ValidateEmail(emailStr); err != nil {
return err
}
// Check if address exists already
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
if email != nil {
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
}
// Insert new address
email = &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: !setting.Service.RegisterEmailConfirm,
IsPrimary: false,
}
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
return err
}
if newEmail != nil {
audit.Record(ctx, audit_model.UserEmailPrimaryChange, u, "email", newEmail.Email)
}
return nil
}
func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error {
for _, emailStr := range emails {
// Check if address exists
email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
if err != nil {
return err
}
if email.IsPrimary {
return user_model.ErrPrimaryEmailCannotDelete{Email: emailStr}
func AddEmailAddresses(ctx context.Context, u *user_model.User, emailsToAdd []string) error {
emails := make([]*user_model.EmailAddress, 0, len(emailsToAdd))
// Audit only after the transaction committed, so a partial batch neither persists nor records.
if err := db.WithTx(ctx, func(ctx context.Context) error {
for _, emailStr := range emailsToAdd {
if err := user_model.ValidateEmail(emailStr); err != nil {
return err
}
// Check if address exists already
email, err := user_model.GetEmailAddressByEmail(ctx, emailStr)
if err != nil && !errors.Is(err, util.ErrNotExist) {
return err
}
if email != nil {
return user_model.ErrEmailAlreadyUsed{Email: emailStr}
}
// Insert new address
email = &user_model.EmailAddress{
UID: u.ID,
Email: emailStr,
IsActivated: !setting.Service.RegisterEmailConfirm,
IsPrimary: false,
}
if _, err := user_model.InsertEmailAddress(ctx, email); err != nil {
return err
}
emails = append(emails, email)
}
// Remove address
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, email.ID); err != nil {
return err
}
return nil
}); err != nil {
return err
}
for _, email := range emails {
audit.Record(ctx, audit_model.UserEmailAdd, u, "email", email.Email)
}
return nil
}
func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emailsToRemove []string) error {
emails := make([]*user_model.EmailAddress, 0, len(emailsToRemove))
// Audit only after the transaction committed, so a partial batch neither persists nor records.
if err := db.WithTx(ctx, func(ctx context.Context) error {
for _, emailStr := range emailsToRemove {
// Check if address exists
email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID)
if err != nil {
return err
}
if email.IsPrimary {
return user_model.ErrPrimaryEmailCannotDelete{Email: emailStr}
}
// Remove address
if _, err := db.DeleteByID[user_model.EmailAddress](ctx, email.ID); err != nil {
return err
}
emails = append(emails, email)
}
return nil
}); err != nil {
return err
}
for _, email := range emails {
audit.Record(ctx, audit_model.UserEmailRemove, u, "email", email.Email)
}
return nil
+36 -2
View File
@@ -7,12 +7,14 @@ import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
password_module "gitea.dev/modules/auth/password"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/services/audit"
)
type UpdateOptionField[T any] struct {
@@ -61,6 +63,8 @@ type UpdateOptions struct {
func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
cols := make([]string, 0, 20)
oldIsActive, oldIsRestricted, oldIsAdmin, oldVisibility := u.IsActive, u.IsRestricted, u.IsAdmin, u.Visibility
if opts.KeepEmailPrivate.Has() {
u.KeepEmailPrivate = opts.KeepEmailPrivate.Value()
@@ -183,7 +187,24 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "last_login_unix")
}
return user_model.UpdateUserCols(ctx, u, cols...)
if err := user_model.UpdateUserCols(ctx, u, cols...); err != nil {
return err
}
if u.IsActive != oldIsActive {
audit.Record(ctx, audit_model.UserActive, u, "active", u.IsActive)
}
if u.IsAdmin != oldIsAdmin {
audit.Record(ctx, audit_model.UserAdmin, u, "admin", u.IsAdmin)
}
if u.IsRestricted != oldIsRestricted {
audit.Record(ctx, audit_model.UserRestricted, u, "restricted", u.IsRestricted)
}
if u.Visibility != oldVisibility {
audit.Record(ctx, audit_model.UserVisibility, u, "old_visibility", oldVisibility.String(), "new_visibility", u.Visibility.String())
}
return nil
}
type UpdateAuthOptions struct {
@@ -195,12 +216,17 @@ type UpdateAuthOptions struct {
}
func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error {
loginSourceChanged := false
authSourceName := ""
if opts.LoginSource.Has() {
source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value())
if err != nil {
return err
}
loginSourceChanged = u.LoginSource != source.ID
authSourceName = source.Name
u.LoginType = source.Type
u.LoginSource = source.ID
}
@@ -241,7 +267,15 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions
}
if deleteAuthTokens {
return auth_model.DeleteAuthTokensByUserID(ctx, u.ID)
if err := auth_model.DeleteAuthTokensByUserID(ctx, u.ID); err != nil {
return err
}
audit.Record(ctx, audit_model.UserPassword, u)
}
if loginSourceChanged {
audit.Record(ctx, audit_model.UserAuthenticationSource, u, "auth_source", authSourceName)
}
return nil
}
+22 -1
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
"gitea.dev/models/organization"
packages_model "gitea.dev/models/packages"
@@ -24,6 +25,7 @@ import (
"gitea.dev/modules/util"
"gitea.dev/services/agit"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
org_service "gitea.dev/services/org"
"gitea.dev/services/packages"
container_service "gitea.dev/services/packages/container"
@@ -56,7 +58,13 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string, doe
u.Name = oldUserName
return err
}
return repo_model.UpdateRepositoryOwnerNames(ctx, u.ID, newUserName)
if err := repo_model.UpdateRepositoryOwnerNames(ctx, u.ID, newUserName); err != nil {
return err
}
recordNameChange(ctx, u, oldUserName)
return nil
}
ctx, committer, err := db.TxContext(ctx)
@@ -114,9 +122,20 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string, doe
}
return err
}
recordNameChange(ctx, u, oldUserName)
return nil
}
func recordNameChange(ctx context.Context, u *user_model.User, oldUserName string) {
if u.IsOrganization() {
audit.Record(ctx, audit_model.OrganizationName, u, "previous_name", oldUserName)
} else {
audit.Record(ctx, audit_model.UserName, u, "previous_name", oldUserName)
}
}
// DeleteUser completely and permanently deletes everything of a user,
// but issues/comments/pulls will be kept and shown as someone has been deleted,
// unless the user is younger than USER_DELETE_WITH_COMMENTS_MAX_DAYS.
@@ -270,6 +289,8 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
}
}
audit.Record(ctx, audit_model.UserDelete, u)
return nil
}
+9
View File
@@ -10,6 +10,7 @@ import (
"time"
activities_model "gitea.dev/models/activities"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
@@ -18,6 +19,7 @@ import (
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
org_service "gitea.dev/services/org"
@@ -177,6 +179,8 @@ func TestRenameUser(t *testing.T) {
})
t.Run("Only capitalization", func(t *testing.T) {
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
caps := strings.ToUpper(user.Name)
unittest.AssertNotExistsBean(t, &user_model.User{ID: user.ID, Name: caps})
unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: user.Name})
@@ -185,6 +189,11 @@ func TestRenameUser(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: caps})
unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: caps})
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.UserName,
ScopeType: audit_model.ScopeUser,
ScopeID: user.ID,
})
})
t.Run("Already exists", func(t *testing.T) {