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,208 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
var (
|
||||
actionMessages = map[Action]string{}
|
||||
allActions []Action
|
||||
)
|
||||
|
||||
func define(id, message string) Action {
|
||||
a := Action(id)
|
||||
if _, exists := actionMessages[a]; exists {
|
||||
panic("duplicate audit action: " + id)
|
||||
}
|
||||
actionMessages[a] = message
|
||||
allActions = append(allActions, a)
|
||||
return a
|
||||
}
|
||||
|
||||
// MessageTemplate returns the message template registered for an action.
|
||||
func MessageTemplate(a Action) (string, bool) {
|
||||
m, ok := actionMessages[a]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// AllActions returns every registered action.
|
||||
func AllActions() []Action {
|
||||
return allActions
|
||||
}
|
||||
|
||||
// ActionFilters returns every exact action and its selectable hierarchy prefixes.
|
||||
// A prefix is useful when an operator wants every event in a family, such as
|
||||
// user:impersonation, without having to download and post-process the log.
|
||||
func ActionFilters() []Action {
|
||||
filters := make(map[Action]struct{}, len(allActions)*2)
|
||||
for _, action := range allActions {
|
||||
filters[action] = struct{}{}
|
||||
parts := strings.Split(string(action), ":")
|
||||
for i := 2; i < len(parts); i++ {
|
||||
filters[Action(strings.Join(parts[:i], ":"))] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]Action, 0, len(filters))
|
||||
for action := range filters {
|
||||
result = append(result, action)
|
||||
}
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// IsActionFilter reports whether action is an exact action or a family prefix.
|
||||
func IsActionFilter(action Action) bool {
|
||||
return slices.Contains(ActionFilters(), action)
|
||||
}
|
||||
|
||||
var (
|
||||
UserImpersonation = define("user:impersonation:start", "User {actor} started impersonating user {scope}.")
|
||||
UserImpersonationExit = define("user:impersonation:exit", "User {actor} stopped impersonating user {scope}.")
|
||||
UserCreate = define("user:create", "Created user {scope}.")
|
||||
UserDelete = define("user:delete", "Deleted user {scope}.")
|
||||
UserAuthenticationFailTwoFactor = define("user:authentication:fail:twofactor", "Failed two-factor authentication for user {scope}.")
|
||||
UserAuthenticationSource = define("user:authentication:source:update", "Changed authentication source of user {scope} to {auth_source}.")
|
||||
UserActive = define("user:status:active", "Changed activation status of user {scope} to {active}.")
|
||||
UserRestricted = define("user:status:restricted", "Changed restricted status of user {scope} to {restricted}.")
|
||||
UserAdmin = define("user:status:admin", "Changed admin status of user {scope} to {admin}.")
|
||||
UserName = define("user:name:update", "Changed user name from {previous_name} to {scope}.")
|
||||
UserPassword = define("user:password", "Changed password of user {scope}.")
|
||||
UserPasswordResetRequest = define("user:password:resetrequest", "Requested password reset for user {scope}.")
|
||||
UserVisibility = define("user:visibility:update", "Changed visibility of user {scope} from {old_visibility} to {new_visibility}.")
|
||||
UserEmailPrimaryChange = define("user:email:primary", "Changed primary email of user {scope} to {email}.")
|
||||
UserEmailAdd = define("user:email:add", "Added email {email} to user {scope}.")
|
||||
UserEmailActivate = define("user:email:activate", "Changed activation status of email {email} of user {scope}.")
|
||||
UserEmailRemove = define("user:email:remove", "Removed email {email} from user {scope}.")
|
||||
UserTwoFactorEnable = define("user:twofactor:enable", "Enabled two-factor authentication for user {scope}.")
|
||||
UserTwoFactorRegenerate = define("user:twofactor:regenerate", "Regenerated two-factor authentication secret for user {scope}.")
|
||||
UserTwoFactorDisable = define("user:twofactor:disable", "Disabled two-factor authentication for user {scope}.")
|
||||
UserWebAuthAdd = define("user:webauth:add", "Added WebAuthn key {credential} for user {scope}.")
|
||||
UserWebAuthRemove = define("user:webauth:remove", "Removed WebAuthn key {credential} from user {scope}.")
|
||||
UserExternalLoginAdd = define("user:externallogin:add", "Added external login {external_id} for user {scope} using provider {provider}.")
|
||||
UserExternalLoginRemove = define("user:externallogin:remove", "Removed external login from authentication source {auth_source_id} for user {scope}.")
|
||||
UserOpenIDAdd = define("user:openid:add", "Associated OpenID {openid} to user {scope}.")
|
||||
UserOpenIDRemove = define("user:openid:remove", "Removed OpenID {openid} from user {scope}.")
|
||||
UserAccessTokenAdd = define("user:accesstoken:add", "Added access token {token} for user {scope} with scope {token_scope}.")
|
||||
UserAccessTokenRemove = define("user:accesstoken:remove", "Removed access token {token} from user {scope}.")
|
||||
UserOAuth2ApplicationAdd = define("user:oauth2application:add", "Added OAuth2 application {oauth2_application} for user {scope}.")
|
||||
UserOAuth2ApplicationUpdate = define("user:oauth2application:update", "Updated OAuth2 application {oauth2_application} of user {scope}.")
|
||||
UserOAuth2ApplicationSecret = define("user:oauth2application:secret", "Regenerated secret for OAuth2 application {oauth2_application} of user {scope}.")
|
||||
UserOAuth2ApplicationGrant = define("user:oauth2application:grant", "Granted OAuth2 application {oauth2_application} access to user {scope}.")
|
||||
UserOAuth2ApplicationRevoke = define("user:oauth2application:revoke", "Revoked OAuth2 grant for application {oauth2_application} of user {scope}.")
|
||||
UserOAuth2ApplicationRemove = define("user:oauth2application:remove", "Removed OAuth2 application {oauth2_application} of user {scope}.")
|
||||
UserKeySSHAdd = define("user:key:ssh:add", "Added SSH key {fingerprint} for user {scope}.")
|
||||
UserKeySSHRemove = define("user:key:ssh:remove", "Removed SSH key {fingerprint} of user {scope}.")
|
||||
UserKeyPrincipalAdd = define("user:key:principal:add", "Added principal key {key} for user {scope}.")
|
||||
UserKeyPrincipalRemove = define("user:key:principal:remove", "Removed principal key {key} of user {scope}.")
|
||||
UserKeyGPGAdd = define("user:key:gpg:add", "Added GPG key {gpg_key_id} for user {scope}.")
|
||||
UserKeyGPGRemove = define("user:key:gpg:remove", "Removed GPG key {gpg_key_id} of user {scope}.")
|
||||
UserSecretAdd = define("user:secret:add", "Added secret {secret} to user {scope}.")
|
||||
UserSecretUpdate = define("user:secret:update", "Updated secret {secret} of user {scope}.")
|
||||
UserSecretRemove = define("user:secret:remove", "Removed secret {secret} from user {scope}.")
|
||||
UserWebhookAdd = define("user:webhook:add", "Added webhook {webhook} to user {scope}.")
|
||||
UserWebhookUpdate = define("user:webhook:update", "Updated webhook {webhook} of user {scope}.")
|
||||
UserWebhookRemove = define("user:webhook:remove", "Removed webhook {webhook} of user {scope}.")
|
||||
|
||||
OrganizationCreate = define("organization:create", "Created organization {scope}.")
|
||||
OrganizationDelete = define("organization:delete", "Deleted organization {scope}.")
|
||||
OrganizationName = define("organization:name:update", "Changed organization name from {previous_name} to {scope}.")
|
||||
OrganizationVisibility = define("organization:visibility", "Changed visibility of organization {scope} to {new_visibility}.")
|
||||
OrganizationMemberRemove = define("organization:member:remove", "Removed user {member} from organization {scope}.")
|
||||
OrganizationTeamAdd = define("organization:team:add", "Added team {team} to organization {scope}.")
|
||||
OrganizationTeamUpdate = define("organization:team:update", "Updated settings of team {scope}/{team}.")
|
||||
OrganizationTeamRemove = define("organization:team:remove", "Removed team {team} from organization {scope}.")
|
||||
OrganizationTeamPermission = define("organization:team:permission", "Changed permission of team {scope}/{team} to {permission}.")
|
||||
OrganizationTeamMemberAdd = define("organization:team:member:add", "Added user {member} to team {scope}/{team}.")
|
||||
OrganizationTeamMemberRemove = define("organization:team:member:remove", "Removed user {member} from team {scope}/{team}.")
|
||||
OrganizationOAuth2ApplicationAdd = define("organization:oauth2application:add", "Added OAuth2 application {oauth2_application} for organization {scope}.")
|
||||
OrganizationOAuth2ApplicationUpdate = define("organization:oauth2application:update", "Updated OAuth2 application {oauth2_application} of organization {scope}.")
|
||||
OrganizationOAuth2ApplicationSecret = define("organization:oauth2application:secret", "Regenerated secret for OAuth2 application {oauth2_application} of organization {scope}.")
|
||||
OrganizationOAuth2ApplicationRemove = define("organization:oauth2application:remove", "Removed OAuth2 application {oauth2_application} of organization {scope}.")
|
||||
OrganizationSecretAdd = define("organization:secret:add", "Added secret {secret} to organization {scope}.")
|
||||
OrganizationSecretUpdate = define("organization:secret:update", "Updated secret {secret} of organization {scope}.")
|
||||
OrganizationSecretRemove = define("organization:secret:remove", "Removed secret {secret} from organization {scope}.")
|
||||
OrganizationWebhookAdd = define("organization:webhook:add", "Added webhook {webhook} to organization {scope}.")
|
||||
OrganizationWebhookUpdate = define("organization:webhook:update", "Updated webhook {webhook} of organization {scope}.")
|
||||
OrganizationWebhookRemove = define("organization:webhook:remove", "Removed webhook {webhook} of organization {scope}.")
|
||||
|
||||
RepositoryCreate = define("repository:create", "Created repository {scope}.")
|
||||
RepositoryCreateFork = define("repository:fork:create", "Created fork {scope} of repository {base_repo}.")
|
||||
RepositoryArchive = define("repository:archive", "Archived repository {scope}.")
|
||||
RepositoryUnarchive = define("repository:unarchive", "Unarchived repository {scope}.")
|
||||
RepositoryDelete = define("repository:delete", "Deleted repository {scope}.")
|
||||
RepositoryName = define("repository:name:update", "Changed repository name from {previous_name} to {scope}.")
|
||||
RepositoryVisibility = define("repository:visibility:update", "Changed visibility of repository {scope} to {visibility}.")
|
||||
RepositoryConvertFork = define("repository:fork:convert", "Converted repository {scope} from fork to regular repository.")
|
||||
RepositoryConvertMirror = define("repository:mirror:convert", "Converted repository {scope} from pull mirror to regular repository.")
|
||||
RepositoryMirrorPushAdd = define("repository:mirror:push:add", "Added push mirror to {remote_address} for repository {scope}.")
|
||||
RepositoryMirrorPushRemove = define("repository:mirror:push:remove", "Removed push mirror to {remote_address} for repository {scope}.")
|
||||
RepositorySigningVerification = define("repository:signingverification", "Changed signing verification of repository {scope} to {trust_model}.")
|
||||
RepositoryTransferStart = define("repository:transfer:start", "Started repository transfer of {scope} to {new_owner}.")
|
||||
RepositoryTransferFinish = define("repository:transfer:finish", "Transferred repository {scope} from {old_owner} to {new_owner}.")
|
||||
RepositoryTransferCancel = define("repository:transfer:cancel", "Canceled transfer of repository {scope}.")
|
||||
RepositoryWikiDelete = define("repository:wiki:delete", "Deleted wiki of repository {scope}.")
|
||||
RepositoryCollaboratorAdd = define("repository:collaborator:add", "Added user {collaborator} as collaborator for repository {scope} with access mode {access_mode}.")
|
||||
RepositoryCollaboratorAccess = define("repository:collaborator:access", "Changed access mode of collaborator {collaborator} of repository {scope} to {access_mode}.")
|
||||
RepositoryCollaboratorRemove = define("repository:collaborator:remove", "Removed collaborator {collaborator} from repository {scope}.")
|
||||
RepositoryCollaboratorTeamAdd = define("repository:collaborator:team:add", "Added team {team} as collaborator for repository {scope}.")
|
||||
RepositoryCollaboratorTeamRemove = define("repository:collaborator:team:remove", "Removed team {team} as collaborator from repository {scope}.")
|
||||
RepositoryBranchDefault = define("repository:branch:default", "Changed default branch of repository {scope} to {default_branch}.")
|
||||
RepositoryBranchProtectionAdd = define("repository:branch:protection:add", "Added branch protection {rule} for repository {scope}.")
|
||||
RepositoryBranchProtectionUpdate = define("repository:branch:protection:update", "Updated branch protection {rule} for repository {scope}.")
|
||||
RepositoryBranchProtectionRemove = define("repository:branch:protection:remove", "Removed branch protection {rule} from repository {scope}.")
|
||||
RepositoryTagProtectionAdd = define("repository:tag:protection:add", "Added tag protection {pattern} for repository {scope}.")
|
||||
RepositoryTagProtectionUpdate = define("repository:tag:protection:update", "Updated tag protection {pattern} for repository {scope}.")
|
||||
RepositoryTagProtectionRemove = define("repository:tag:protection:remove", "Removed tag protection {pattern} from repository {scope}.")
|
||||
RepositoryWebhookAdd = define("repository:webhook:add", "Added webhook {webhook} to repository {scope}.")
|
||||
RepositoryWebhookUpdate = define("repository:webhook:update", "Updated webhook {webhook} of repository {scope}.")
|
||||
RepositoryWebhookRemove = define("repository:webhook:remove", "Removed webhook {webhook} of repository {scope}.")
|
||||
RepositoryDeployKeyAdd = define("repository:deploykey:add", "Added deploy key {deploy_key} for repository {scope}.")
|
||||
RepositoryDeployKeyRemove = define("repository:deploykey:remove", "Removed deploy key {deploy_key} from repository {scope}.")
|
||||
RepositorySecretAdd = define("repository:secret:add", "Added secret {secret} to repository {scope}.")
|
||||
RepositorySecretUpdate = define("repository:secret:update", "Updated secret {secret} of repository {scope}.")
|
||||
RepositorySecretRemove = define("repository:secret:remove", "Removed secret {secret} from repository {scope}.")
|
||||
|
||||
IssueCreate = define("issue:create", "Created issue {issue} in repository {scope}.")
|
||||
IssueDelete = define("issue:delete", "Deleted issue {issue} from repository {scope}.")
|
||||
IssueCommentCreate = define("issue:comment:create", "Added comment {comment_id} to issue {issue} in repository {scope}.")
|
||||
IssueCommentDelete = define("issue:comment:delete", "Deleted comment {comment_id} from issue {issue} in repository {scope}.")
|
||||
|
||||
PullRequestCreate = define("pr:create", "Created pull request {pull_request} in repository {scope}.")
|
||||
PullRequestDelete = define("pr:delete", "Deleted pull request {pull_request} from repository {scope}.")
|
||||
PullRequestMerge = define("pr:merge", "Merged pull request {pull_request} in repository {scope}.")
|
||||
PullRequestCommentCreate = define("pr:comment:create", "Added comment {comment_id} to pull request {pull_request} in repository {scope}.")
|
||||
PullRequestCommentDelete = define("pr:comment:delete", "Deleted comment {comment_id} from pull request {pull_request} in repository {scope}.")
|
||||
|
||||
ProjectCreate = define("project:create", "Created project {project} in {scope}.")
|
||||
ProjectUpdate = define("project:update", "Updated project {project} in {scope}.")
|
||||
ProjectDelete = define("project:delete", "Deleted project {project} from {scope}.")
|
||||
|
||||
WikiPageCreate = define("wiki:page:create", "Created wiki page {page} in repository {scope}.")
|
||||
WikiPageUpdate = define("wiki:page:update", "Updated wiki page {page} in repository {scope}.")
|
||||
WikiPageDelete = define("wiki:page:delete", "Deleted wiki page {page} from repository {scope}.")
|
||||
|
||||
ActionsWorkflowEnable = define("actions:workflow:enable", "Enabled Actions workflow {workflow} in repository {scope}.")
|
||||
ActionsWorkflowDisable = define("actions:workflow:disable", "Disabled Actions workflow {workflow} in repository {scope}.")
|
||||
ActionsWorkflowDispatch = define("actions:workflow:dispatch", "Dispatched Actions workflow {workflow} on {ref} in repository {scope}.")
|
||||
|
||||
// Do not change the startup message anymore. We guarantee the stability of this message for
|
||||
// users wanting to parse the log themselves to be able to trace back events across gitea versions.
|
||||
SystemStartup = define("system:startup", "System started [Gitea {version}]")
|
||||
SystemShutdown = define("system:shutdown", "System shutdown")
|
||||
SystemWebhookAdd = define("system:webhook:add", "Added instance-wide webhook {webhook}.")
|
||||
SystemWebhookUpdate = define("system:webhook:update", "Updated instance-wide webhook {webhook}.")
|
||||
SystemWebhookRemove = define("system:webhook:remove", "Removed instance-wide webhook {webhook}.")
|
||||
SystemAuthenticationSourceAdd = define("system:authenticationsource:add", "Created authentication source {auth_source}.")
|
||||
SystemAuthenticationSourceUpdate = define("system:authenticationsource:update", "Updated authentication source {auth_source}.")
|
||||
SystemAuthenticationSourceRemove = define("system:authenticationsource:remove", "Removed authentication source {auth_source}.")
|
||||
SystemOAuth2ApplicationAdd = define("system:oauth2application:add", "Added instance-wide OAuth2 application {oauth2_application}.")
|
||||
SystemOAuth2ApplicationUpdate = define("system:oauth2application:update", "Updated instance-wide OAuth2 application {oauth2_application}.")
|
||||
SystemOAuth2ApplicationSecret = define("system:oauth2application:secret", "Regenerated secret for instance-wide OAuth2 application {oauth2_application}.")
|
||||
SystemOAuth2ApplicationRemove = define("system:oauth2application:remove", "Removed instance-wide OAuth2 application {oauth2_application}.")
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(Event))
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Action Action `xorm:"INDEX NOT NULL"`
|
||||
ActorID int64 `xorm:"INDEX NOT NULL"`
|
||||
ActorName string
|
||||
ActorCredential string // Credential the actor acted with, e.g. "access-token:<id>", "oauth2-grant:<id>", "gitea-actions:<task id>" or "deploy-key:<key id>".
|
||||
ImpersonatorID int64 `xorm:"INDEX"` // Admin acting as the actor; zero when the actor acted themselves.
|
||||
ImpersonatorName string
|
||||
ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` // Entity ID within ScopeType; zero for system.
|
||||
ScopeType ScopeType `xorm:"INDEX INDEX(scope) NOT NULL"`
|
||||
ScopeName string
|
||||
Origin Origin `xorm:"INDEX NOT NULL"`
|
||||
Message string
|
||||
Metadata string `xorm:"LONGTEXT JSON"`
|
||||
IPAddress string
|
||||
TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"`
|
||||
}
|
||||
|
||||
func (*Event) TableName() string {
|
||||
return "audit_event"
|
||||
}
|
||||
|
||||
func (e *Event) Actor() EntityRef {
|
||||
return EntityRef{Type: ScopeUser, ID: e.ActorID, Name: e.ActorName}
|
||||
}
|
||||
|
||||
// Impersonator returns the admin who acted as the actor, or nil.
|
||||
func (e *Event) Impersonator() *EntityRef {
|
||||
if e.ImpersonatorID == 0 && e.ImpersonatorName == "" {
|
||||
return nil
|
||||
}
|
||||
return &EntityRef{Type: ScopeUser, ID: e.ImpersonatorID, Name: e.ImpersonatorName}
|
||||
}
|
||||
|
||||
func (e *Event) Scope() EntityRef {
|
||||
return EntityRef{Type: e.ScopeType, ID: e.ScopeID, Name: e.ScopeName}
|
||||
}
|
||||
|
||||
func (e *Event) Time() time.Time {
|
||||
return e.TimestampUnix.AsTime()
|
||||
}
|
||||
|
||||
// eventJSON is the nested JSONL export shape.
|
||||
type eventJSON struct {
|
||||
Action Action `json:"action"`
|
||||
Actor EntityRef `json:"actor"`
|
||||
ActorCredential string `json:"actor_credential,omitempty"`
|
||||
Impersonator *EntityRef `json:"impersonator,omitempty"`
|
||||
Scope EntityRef `json:"scope"`
|
||||
Message string `json:"message"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
Time time.Time `json:"time"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Origin Origin `json:"origin"`
|
||||
}
|
||||
|
||||
func (e *Event) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(eventJSON{
|
||||
Action: e.Action,
|
||||
Actor: e.Actor(),
|
||||
ActorCredential: e.ActorCredential,
|
||||
Impersonator: e.Impersonator(),
|
||||
Scope: e.Scope(),
|
||||
Message: e.Message,
|
||||
Metadata: DecodeMetadata(e.Metadata),
|
||||
Time: e.Time(),
|
||||
IPAddress: e.IPAddress,
|
||||
Origin: e.Origin,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Event) UnmarshalJSON(data []byte) error {
|
||||
var j eventJSON
|
||||
if err := json.Unmarshal(data, &j); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Action = j.Action
|
||||
e.ActorID = j.Actor.ID
|
||||
e.ActorName = j.Actor.Name
|
||||
e.ActorCredential = j.ActorCredential
|
||||
if j.Impersonator != nil {
|
||||
e.ImpersonatorID = j.Impersonator.ID
|
||||
e.ImpersonatorName = j.Impersonator.Name
|
||||
}
|
||||
e.ScopeType = j.Scope.Type
|
||||
e.ScopeID = j.Scope.ID
|
||||
e.ScopeName = j.Scope.Name
|
||||
e.Message = j.Message
|
||||
e.Metadata = EncodeMetadata(j.Metadata)
|
||||
e.IPAddress = j.IPAddress
|
||||
e.Origin = j.Origin
|
||||
e.TimestampUnix = timeutil.TimeStamp(j.Time.Unix())
|
||||
return nil
|
||||
}
|
||||
|
||||
func EncodeMetadata(m map[string]any) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
log.Error("Failed to encode audit metadata: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func DecodeMetadata(raw string) map[string]any {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
log.Error("Failed to decode audit metadata: %v", err)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func InsertEvent(ctx context.Context, e *Event) error {
|
||||
return db.Insert(ctx, e)
|
||||
}
|
||||
|
||||
// DeleteOldEvents removes events older than the given duration, keeping everything if it is not positive.
|
||||
func DeleteOldEvents(ctx context.Context, olderThan time.Duration) error {
|
||||
if olderThan <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := db.GetEngine(ctx).Where("timestamp_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Event{})
|
||||
return err
|
||||
}
|
||||
|
||||
type EventSort string
|
||||
|
||||
const (
|
||||
SortTimestampAsc EventSort = "timestamp_asc"
|
||||
SortTimestampDesc EventSort = "timestamp_desc"
|
||||
)
|
||||
|
||||
type EventSearchOptions struct {
|
||||
db.ListOptions
|
||||
Action Action
|
||||
// ActionPrefix filters an action family. It is mutually exclusive with Action.
|
||||
ActionPrefix Action
|
||||
ActorID int64
|
||||
ScopeType ScopeType
|
||||
ScopeID int64
|
||||
Origin Origin
|
||||
Sort EventSort
|
||||
}
|
||||
|
||||
func (opts *EventSearchOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
|
||||
if opts.Action != "" {
|
||||
cond = cond.And(builder.Eq{"action": opts.Action})
|
||||
} else if opts.ActionPrefix != "" {
|
||||
cond = cond.And(builder.Like{"action", string(opts.ActionPrefix) + ":%"})
|
||||
}
|
||||
if opts.ActorID != 0 {
|
||||
// an impersonated event belongs to both the actor and the admin behind it
|
||||
cond = cond.And(builder.Eq{"actor_id": opts.ActorID}.Or(builder.Eq{"impersonator_id": opts.ActorID}))
|
||||
}
|
||||
// applied independently so a missing scope ID narrows the query instead of
|
||||
// silently widening it to every scope
|
||||
if opts.ScopeType != "" {
|
||||
cond = cond.And(builder.Eq{"scope_type": opts.ScopeType})
|
||||
}
|
||||
if opts.ScopeID != 0 {
|
||||
cond = cond.And(builder.Eq{"scope_id": opts.ScopeID})
|
||||
}
|
||||
if opts.Origin != "" {
|
||||
cond = cond.And(builder.Eq{"origin": opts.Origin})
|
||||
}
|
||||
|
||||
return cond
|
||||
}
|
||||
|
||||
func (opts *EventSearchOptions) ToOrders() string {
|
||||
if opts.Sort == SortTimestampAsc {
|
||||
return "timestamp_unix ASC, id ASC"
|
||||
}
|
||||
return "timestamp_unix DESC, id DESC"
|
||||
}
|
||||
|
||||
func FindEvents(ctx context.Context, opts *EventSearchOptions) ([]*Event, int64, error) {
|
||||
return db.FindAndCount[Event](ctx, opts)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// BenchmarkInsertEvent measures the synchronous database cost added when audit
|
||||
// recording is enabled. Keep it separate from router benchmarks so it remains
|
||||
// comparable across changes to request handling.
|
||||
func BenchmarkInsertEvent(b *testing.B) {
|
||||
if err := unittest.PrepareTestDatabase(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
event := &Event{
|
||||
Action: UserPassword,
|
||||
ActorID: 1,
|
||||
ActorName: "actor",
|
||||
ScopeType: ScopeUser,
|
||||
ScopeID: 2,
|
||||
ScopeName: "scope",
|
||||
Origin: OriginUI,
|
||||
Metadata: `{"source":"benchmark"}`,
|
||||
TimestampUnix: timeutil.TimeStamp(i + 1),
|
||||
}
|
||||
if err := InsertEvent(ctx, event); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindEventsScopeFilters(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
events := []*Event{
|
||||
{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 5, Origin: OriginUI, TimestampUnix: timeutil.TimeStamp(1)},
|
||||
{Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 5, Origin: OriginAPI, TimestampUnix: timeutil.TimeStamp(1)},
|
||||
{Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 6, Origin: OriginCLI, TimestampUnix: timeutil.TimeStamp(1)},
|
||||
{Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 7, Origin: OriginSystem, TimestampUnix: timeutil.TimeStamp(1)},
|
||||
}
|
||||
for _, event := range events {
|
||||
require.NoError(t, InsertEvent(t.Context(), event))
|
||||
}
|
||||
|
||||
byType, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeType: ScopeRepository})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byType, 3)
|
||||
|
||||
byID, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeID: 5})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byID, 2)
|
||||
|
||||
byScope, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeType: ScopeRepository, ScopeID: 5})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byScope, 1)
|
||||
|
||||
byOrigin, _, err := FindEvents(t.Context(), &EventSearchOptions{Origin: OriginAPI})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byOrigin, 1)
|
||||
|
||||
bySystemOrigin, _, err := FindEvents(t.Context(), &EventSearchOptions{Origin: OriginSystem})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, bySystemOrigin, 1)
|
||||
}
|
||||
|
||||
func TestFindEventsActionPrefixFilter(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
for _, action := range []Action{UserImpersonation, UserImpersonationExit, UserCreate} {
|
||||
require.NoError(t, InsertEvent(t.Context(), &Event{Action: action, ScopeType: ScopeUser, ScopeID: 1, TimestampUnix: timeutil.TimeStamp(1)}))
|
||||
}
|
||||
|
||||
events, _, err := FindEvents(t.Context(), &EventSearchOptions{ActionPrefix: "user:impersonation"})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, events, 2)
|
||||
|
||||
exact, _, err := FindEvents(t.Context(), &EventSearchOptions{Action: UserImpersonationExit})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, exact, 1)
|
||||
assert.Equal(t, UserImpersonationExit, exact[0].Action)
|
||||
}
|
||||
|
||||
// Filtering for an admin must surface what they did while impersonating someone.
|
||||
func TestFindEventsActorFilterIncludesImpersonations(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
events := []*Event{
|
||||
{Action: UserPassword, ActorID: 10, ScopeType: ScopeUser, ScopeID: 10, TimestampUnix: timeutil.TimeStamp(1)},
|
||||
{Action: UserPassword, ActorID: 11, ImpersonatorID: 10, ScopeType: ScopeUser, ScopeID: 11, TimestampUnix: timeutil.TimeStamp(2)},
|
||||
{Action: UserPassword, ActorID: 12, ScopeType: ScopeUser, ScopeID: 12, TimestampUnix: timeutil.TimeStamp(3)},
|
||||
}
|
||||
for _, event := range events {
|
||||
require.NoError(t, InsertEvent(t.Context(), event))
|
||||
}
|
||||
|
||||
byAdmin, _, err := FindEvents(t.Context(), &EventSearchOptions{ActorID: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byAdmin, 2)
|
||||
|
||||
byImpersonated, _, err := FindEvents(t.Context(), &EventSearchOptions{ActorID: 11})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byImpersonated, 1)
|
||||
}
|
||||
|
||||
func TestDeleteOldEvents(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
now := time.Now()
|
||||
old := &Event{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 1, TimestampUnix: timeutil.TimeStamp(now.Add(-48 * time.Hour).Unix())}
|
||||
recent := &Event{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 2, TimestampUnix: timeutil.TimeStamp(now.Unix())}
|
||||
require.NoError(t, InsertEvent(t.Context(), old))
|
||||
require.NoError(t, InsertEvent(t.Context(), recent))
|
||||
|
||||
require.NoError(t, DeleteOldEvents(t.Context(), 0)) // keeps everything
|
||||
_, count, err := FindEvents(t.Context(), &EventSearchOptions{})
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 2, count)
|
||||
|
||||
require.NoError(t, DeleteOldEvents(t.Context(), 24*time.Hour))
|
||||
remaining, _, err := FindEvents(t.Context(), &EventSearchOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, 1)
|
||||
assert.Equal(t, recent.ID, remaining[0].ID)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// EntityRef is a denormalized reference persisted at record time.
|
||||
type EntityRef struct {
|
||||
Type ScopeType `json:"type"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
func (r EntityRef) DisplayName() string {
|
||||
if r.Name != "" {
|
||||
return r.Name
|
||||
}
|
||||
if r.Type == ScopeSystem {
|
||||
return "System"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r EntityRef) HomeLink() string {
|
||||
switch r.Type {
|
||||
case ScopeUser, ScopeOrganization:
|
||||
if r.Name == "" {
|
||||
return ""
|
||||
}
|
||||
return setting.AppSubURL + "/" + url.PathEscape(r.Name)
|
||||
case ScopeRepository:
|
||||
if r.Name == "" {
|
||||
return ""
|
||||
}
|
||||
return setting.AppSubURL + "/" + util.PathEscapeSegments(r.Name)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (r EntityRef) HasLink() bool {
|
||||
return r.HomeLink() != "" && r.ID > 0
|
||||
}
|
||||
@@ -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,14 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
// Origin identifies how an audit event was initiated.
|
||||
type Origin string
|
||||
|
||||
const (
|
||||
OriginUI Origin = "ui"
|
||||
OriginAPI Origin = "api"
|
||||
OriginCLI Origin = "cli"
|
||||
OriginSystem Origin = "system"
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
// ScopeType identifies the unit an audit event belongs to (for filtering in UI).
|
||||
// Target-specific details live in Metadata, not as typed objects in the audit package.
|
||||
type ScopeType string
|
||||
|
||||
const (
|
||||
ScopeSystem ScopeType = "system"
|
||||
ScopeUser ScopeType = "user"
|
||||
ScopeOrganization ScopeType = "organization"
|
||||
ScopeRepository ScopeType = "repository"
|
||||
)
|
||||
@@ -158,6 +158,17 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAccessTokenByID returns the access token with the given ID owned by userID.
|
||||
func GetAccessTokenByID(ctx context.Context, id, userID int64) (*AccessToken, error) {
|
||||
t, has, err := db.Get[AccessToken](ctx, builder.Eq{"id": id, "uid": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, util.NewNotExistErrorf("access token not found")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DeleteAccessTokenByID deletes access token by given ID.
|
||||
func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
|
||||
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID})
|
||||
|
||||
@@ -80,6 +80,17 @@ func AddUserOpenID(ctx context.Context, openid *UserOpenID) error {
|
||||
return db.Insert(ctx, openid)
|
||||
}
|
||||
|
||||
// GetUserOpenIDByID returns the OpenID with the given ID owned by uid.
|
||||
func GetUserOpenIDByID(ctx context.Context, id, uid int64) (*UserOpenID, error) {
|
||||
oid, has, err := db.Get[UserOpenID](ctx, builder.Eq{"id": id, "uid": uid})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, util.NewNotExistErrorf("OpenID is unknown")
|
||||
}
|
||||
return oid, nil
|
||||
}
|
||||
|
||||
// DeleteUserOpenID deletes an openid address of given user.
|
||||
func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) {
|
||||
var deleted int64
|
||||
|
||||
@@ -90,6 +90,32 @@ func NewDeployKeyUserWithKeyID(id int64) *User {
|
||||
return u
|
||||
}
|
||||
|
||||
const (
|
||||
CLIUserID int64 = -4
|
||||
CLIUserName = "CLI"
|
||||
)
|
||||
|
||||
func NewCLIUser() *User {
|
||||
return &User{
|
||||
ID: CLIUserID,
|
||||
Name: CLIUserName,
|
||||
LowerName: strings.ToLower(CLIUserName),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
AuthenticationSourceUserID int64 = -5
|
||||
AuthenticationSourceUserName = "AuthenticationSource"
|
||||
)
|
||||
|
||||
func NewAuthenticationSourceUser() *User {
|
||||
return &User{
|
||||
ID: AuthenticationSourceUserID,
|
||||
Name: AuthenticationSourceUserName,
|
||||
LowerName: strings.ToLower(AuthenticationSourceUserName),
|
||||
}
|
||||
}
|
||||
|
||||
func GetSystemUserByName(name string) *User {
|
||||
lowerName := strings.ToLower(name)
|
||||
uid := globalVars().systemUserNameIdMap[lowerName]
|
||||
|
||||
Reference in New Issue
Block a user