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 08:15:23 +00:00
committed by GitHub
co-authored by bircni wxiaoguang
parent 4d43445532
commit da37b7916b
136 changed files with 3863 additions and 208 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ var (
func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers {
return &user_setting.OAuth2CommonHandlers{
OwnerID: 0,
Owner: nil, // instance-wide
BasePathList: setting.AppSubURL + "/-/admin/applications",
BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2",
TplAppEdit: tplSettingsOauth2ApplicationEdit,
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"fmt"
"net/http"
"time"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
shared_audit "gitea.dev/routers/web/shared/audit"
"gitea.dev/services/audit"
"gitea.dev/services/context"
)
const auditExportPageSize = 1000
func ViewAuditLogs(ctx *context.Context) {
shared_audit.View(ctx, shared_audit.ViewOptions{
Template: "admin/audit/list",
PageData: map[string]any{"PageIsAdminMonitorAudit": true},
})
}
func ExportAuditLogs(ctx *context.Context) {
// the export mirrors the filters of the listing it was started from
searchOpts := shared_audit.SearchOptionsFromRequest(ctx, "", 0)
searchOpts.Sort = audit_model.SortTimestampAsc
searchOpts.PageSize = auditExportPageSize
page := 1
findPage := func() ([]*audit_model.Event, int64, error) {
searchOpts.Page = page
return audit.FindEvents(ctx, searchOpts)
}
// the first page is fetched before any header is written so a failing query still results in a proper error page
events, total, err := findPage()
if err != nil {
ctx.ServerError("FindEvents", err)
return
}
httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{
ContentType: "application/x-ndjson; charset=utf-8",
Filename: fmt.Sprintf("gitea-audit-log-%s.jsonl", time.Now().UTC().Format("20060102-150405Z")),
ContentDisposition: httplib.ContentDispositionAttachment,
})
ctx.SetTotalCountHeader(total) // lets a client detect an export truncated by a mid-stream failure
ctx.Resp.WriteHeader(http.StatusOK)
for {
if err := audit.WriteEventsAsJSON(ctx.Resp, events); err != nil {
log.Debug("Unable to write audit log export: %v", err)
return
}
if len(events) < auditExportPageSize {
return
}
page++
events, _, err = findPage()
if err != nil {
log.Error("Unable to continue audit log export: %v", err)
return
}
}
}
+2 -2
View File
@@ -301,7 +301,7 @@ func NewAuthSourcePost(ctx *context.Context) {
return
}
if err := auth.CreateSource(ctx, &auth.Source{
if err := auth_service.CreateSource(ctx, &auth.Source{
Type: auth.Type(form.Type),
Name: form.Name,
IsActive: form.IsActive,
@@ -419,7 +419,7 @@ func EditAuthSourcePost(ctx *context.Context) {
source.IsSyncEnabled = form.IsSyncEnabled
source.Cfg = config
source.TwoFactorPolicy = form.TwoFactorPolicy
if err := auth.UpdateSource(ctx, source); err != nil {
if err := auth_service.UpdateSource(ctx, source); err != nil {
if errExist, ok := errors.AsType[auth.ErrSourceAlreadyExist](err); ok {
ctx.Data["Err_Name"] = true
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_exist", errExist.Name), tplAuthEdit, form)
+9
View File
@@ -8,12 +8,14 @@ import (
"net/http"
"net/url"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/audit"
"gitea.dev/services/context"
"gitea.dev/services/user"
)
@@ -129,6 +131,13 @@ func ActivateEmail(ctx *context.Context) {
ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err))
}
} else {
// the email already changed, so a failed lookup must not fail the request
if u, err := user_model.GetUserByID(ctx, uid); err != nil {
log.Error("GetUserByID(%d) for audit: %v", uid, err)
} else {
audit.Record(ctx, audit_model.UserEmailActivate, u, "email", email, "activated", activate)
}
log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate)
ctx.Flash.Info(ctx.Tr("admin.emails.updated"))
}
+7 -1
View File
@@ -10,6 +10,7 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/audit"
"gitea.dev/services/context"
)
@@ -61,9 +62,14 @@ func DefaultOrSystemWebhooks(ctx *context.Context) {
// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook
func DeleteDefaultOrSystemWebhook(ctx *context.Context) {
if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil {
hook, err := webhook.GetWebhookByID(ctx, ctx.FormInt64("id"))
if err != nil {
ctx.Flash.Error("GetWebhookByID: " + err.Error())
} else if err := webhook.DeleteDefaultSystemWebhook(ctx, hook.ID); err != nil {
ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error())
} else {
audit.RecordScoped(ctx, nil, nil, audit.WebhookRemove, "webhook", hook.URL)
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
}
+5
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
@@ -25,6 +26,7 @@ import (
"gitea.dev/modules/web"
"gitea.dev/routers/web/explore"
user_setting "gitea.dev/routers/web/user/setting"
"gitea.dev/services/audit"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/context"
"gitea.dev/services/forms"
@@ -203,6 +205,8 @@ func NewUserPost(ctx *context.Context) {
ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email))
}
audit.Record(ctx, audit_model.UserCreate, u)
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
// Send email notification.
@@ -468,6 +472,7 @@ func ImpersonateUser(ctx *context.Context) {
ctx.ServerError("unable to impersonate user", err)
return
}
audit.Record(ctx, audit_model.UserImpersonation, u)
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
}