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
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"maps"
"net/http"
"slices"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
"gitea.dev/services/context"
)
// ViewOptions configures a scoped audit log listing. The admin view leaves
// ScopeType empty to list every event; the user/org/repo views constrain the
// query to their own scope.
type ViewOptions struct {
Template templates.TplName
ScopeType audit_model.ScopeType
ScopeID int64
// PageData holds ctx.Data flags to enable for the active navigation tab.
PageData map[string]any
}
var filterableOrigins = []audit_model.Origin{audit_model.OriginUI, audit_model.OriginAPI, audit_model.OriginCLI, audit_model.OriginSystem}
// SearchOptionsFromRequest builds the event query from the request filters and
// publishes the applied values to ctx.Data so the filter form can render its
// current state. The listing and the export share it to stay in sync.
func SearchOptionsFromRequest(ctx *context.Context, scopeType audit_model.ScopeType, scopeID int64) *audit_model.EventSearchOptions {
// only the two known sort values are accepted, anything else falls back to the default
sort := util.Iif(audit_model.EventSort(ctx.FormString("sort")) == audit_model.SortTimestampAsc, audit_model.SortTimestampAsc, audit_model.SortTimestampDesc)
opts := &audit_model.EventSearchOptions{
Sort: sort,
ScopeType: scopeType,
ScopeID: scopeID,
}
if action := audit_model.Action(ctx.FormString("action")); action != "" {
if _, ok := audit_model.MessageTemplate(action); ok {
opts.Action = action
} else if audit_model.IsActionFilter(action) {
opts.ActionPrefix = action
}
}
if origin := audit_model.Origin(ctx.FormString("origin")); slices.Contains(filterableOrigins, origin) {
opts.Origin = origin
}
if actor := ctx.FormTrim("actor"); actor != "" {
u, err := user_model.GetUserByName(ctx, actor)
if err != nil {
opts.ActorID = -1 // an unknown actor matches nothing rather than everything
} else {
opts.ActorID = u.ID
}
}
ctx.Data["AuditSort"] = string(opts.Sort)
ctx.Data["AuditFilterAction"] = string(opts.Action)
if opts.ActionPrefix != "" {
ctx.Data["AuditFilterAction"] = string(opts.ActionPrefix)
}
ctx.Data["AuditFilterOrigin"] = string(opts.Origin)
ctx.Data["AuditFilterActor"] = ctx.FormTrim("actor")
ctx.Data["AuditActions"] = audit_model.ActionFilters()
ctx.Data["AuditOrigins"] = filterableOrigins
return opts
}
// View renders a paginated audit log listing shared by the admin, user, org and
// repo settings pages. Only the scope filter, template and page flags differ.
func View(ctx *context.Context, opts ViewOptions) {
ctx.Data["Title"] = ctx.Tr("audit.title")
ctx.Data["AuditRecordEnabled"] = setting.AuditRecordEnabled()
maps.Copy(ctx.Data, opts.PageData)
page := max(ctx.FormInt("page"), 1)
searchOpts := SearchOptionsFromRequest(ctx, opts.ScopeType, opts.ScopeID)
searchOpts.ListOptions = db.ListOptions{
Page: page,
PageSize: setting.UI.Admin.NoticePagingNum,
}
evs, total, err := audit.FindEvents(ctx, searchOpts)
if err != nil {
ctx.ServerError("FindEvents", err)
return
}
ctx.Data["AuditEvents"] = evs
ctx.Data["Page"] = context.NewPagerBuilder(ctx).TotalCount(total).PerPageLimit(setting.UI.Admin.NoticePagingNum).CurPage(page).Build()
ctx.HTML(http.StatusOK, opts.Template)
}
+28 -5
View File
@@ -5,10 +5,13 @@ package secrets
import (
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
secret_model "gitea.dev/models/secret"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/services/audit"
"gitea.dev/services/context"
"gitea.dev/services/forms"
secret_service "gitea.dev/services/secrets"
@@ -26,29 +29,49 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
ctx.Data["DescriptionMaxLength"] = secret_model.SecretDescriptionMaxLength
}
func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
form := web.GetForm[*forms.AddSecretForm](ctx)
func secretOwnerRepoIDs(owner *user_model.User, repo *repo_model.Repository) (ownerID, repoID int64) {
if owner != nil {
ownerID = owner.ID
}
if repo != nil {
repoID = repo.ID
}
return ownerID, repoID
}
s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description)
func PerformSecretsPost(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository, redirectURL string) {
form := web.GetForm[*forms.AddSecretForm](ctx)
ownerID, repoID := secretOwnerRepoIDs(owner, repo)
s, created, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description)
if err != nil {
ctx.JSONErrorAuto(err)
return
}
actions := audit.SecretUpdate
if created {
actions = audit.SecretAdd
}
audit.RecordScoped(ctx, owner, repo, actions, "secret", s.Name)
ctx.Flash.Success(ctx.Tr("secrets.save_success", s.Name))
ctx.JSONRedirect(redirectURL)
}
func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
func PerformSecretsDelete(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository, redirectURL string) {
id := ctx.FormInt64("id")
ownerID, repoID := secretOwnerRepoIDs(owner, repo)
err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id)
s, err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id)
if err != nil {
log.Error("DeleteSecretByID(%d) failed: %v", id, err)
ctx.JSONError(ctx.Tr("secrets.deletion.failed"))
return
}
audit.RecordScoped(ctx, owner, repo, audit.SecretRemove, "secret", s.Name)
ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
ctx.JSONRedirect(redirectURL)
}