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
+22 -3
View File
@@ -4,11 +4,13 @@
package actions
import (
"context"
"fmt"
"gitea.dev/actionslib/pkg/exprparser"
"gitea.dev/actionslib/pkg/model"
actions_model "gitea.dev/models/actions"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
@@ -20,11 +22,12 @@ import (
"gitea.dev/modules/reqctx"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/context"
"gitea.dev/services/audit"
gitea_context "gitea.dev/services/context"
"gitea.dev/services/convert"
)
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
func EnableOrDisableWorkflow(ctx *gitea_context.APIContext, workflowID string, isEnable bool) error {
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
if err != nil {
return err
@@ -39,7 +42,21 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
cfg.DisableWorkflow(workflow.ID)
}
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil {
return err
}
RecordWorkflowToggle(ctx, ctx.Repo.Repository, workflow.ID, isEnable)
return nil
}
// RecordWorkflowToggle writes the enable/disable audit event for a workflow.
func RecordWorkflowToggle(ctx context.Context, repo *repo_model.Repository, workflowID string, isEnable bool) {
action := audit_model.ActionsWorkflowDisable
if isEnable {
action = audit_model.ActionsWorkflowEnable
}
audit.Record(ctx, action, repo, "workflow", workflowID)
}
// DispatchActionWorkflow manually triggers a workflow_dispatch run.
@@ -163,6 +180,8 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil {
return 0, fmt.Errorf("PrepareRun: %w", err)
}
audit.RecordAs(ctx, doer, audit_model.ActionsWorkflowDispatch, repo,
"workflow", workflowID, "ref", ref, "run_id", run.ID)
return run.ID, nil
}
+5
View File
@@ -8,9 +8,11 @@ import (
"fmt"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
deploykey_model "gitea.dev/models/deploykey"
repo_model "gitea.dev/models/repo"
"gitea.dev/services/audit"
)
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
@@ -64,6 +66,9 @@ func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64)
if err != nil {
return nil, err
}
audit.Record(ctx, audit_model.RepositoryDeployKeyRemove, repo, "deploy_key", deleted.Name)
if deleted.KeyType == deploykey_model.KeyTypeToken {
return deleted, nil // a token never appears in the authorized_keys file
}
+8
View File
@@ -7,8 +7,10 @@ import (
"context"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
)
// DeletePublicKey deletes SSH key information both in database and authorized_keys file.
@@ -31,9 +33,15 @@ func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err
return err
}
owner := audit.ScopeFromUserID(ctx, key.OwnerID)
if key.Type == asymkey_model.KeyTypePrincipal {
audit.Record(ctx, audit_model.UserKeyPrincipalRemove, owner, "key", key.Name)
return RewriteAllPrincipalKeys(ctx)
}
audit.Record(ctx, audit_model.UserKeySSHRemove, owner, "fingerprint", key.Fingerprint)
return RewriteAllPublicKeys(ctx)
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
user_model "gitea.dev/models/user"
"gitea.dev/modules/web/middleware"
)
type doerContextKeyType struct{}
var doerContextKey doerContextKeyType
type impersonatorContextKeyType struct{}
var impersonatorContextKey impersonatorContextKeyType
// WithImpersonator returns a context that records audit events as performed by
// the doer on behalf of the given admin.
func WithImpersonator(ctx context.Context, impersonator *user_model.User) context.Context {
return context.WithValue(ctx, impersonatorContextKey, impersonator)
}
// WithDoer returns a context that records audit events as the given user.
//
// Web and API requests need this only in unusual cases: the signed-in user is
// already published to the request data store by routers/common.AuthShared and
// is picked up automatically. Use it at entry points that have no signed-in
// user - the CLI, cron tasks, authentication source syncs and git hooks - before
// calling into services that record audit events themselves.
func WithDoer(ctx context.Context, doer *user_model.User) context.Context {
return context.WithValue(ctx, doerContextKey, doer)
}
// doerFromContext resolves the actor of an audit event: an explicit WithDoer
// value wins over the signed-in user of the surrounding request. Returns nil
// when neither is available, which Record turns into an unknown actor.
func doerFromContext(ctx context.Context) *user_model.User {
if doer, ok := ctx.Value(doerContextKey).(*user_model.User); ok && doer != nil {
return doer
}
if data := middleware.GetContextData(ctx); data != nil {
if doer, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
return doer
}
}
return nil
}
// credentialFromContext returns the credential the surrounding request
// authenticated with. It is dropped when the event is recorded for someone
// other than the signed-in user, so an explicit actor is never tied to a
// credential that is not theirs.
func credentialFromContext(ctx context.Context, doer *user_model.User) string {
data := middleware.GetContextData(ctx)
if data == nil {
return ""
}
signedUser, _ := data[middleware.ContextDataKeySignedUser].(*user_model.User)
if signedUser == nil || signedUser.ID != doer.ID {
return ""
}
credential, _ := data[middleware.ContextDataKeyAuthCredential].(string)
return credential
}
// ImpersonatorFromContext resolves the admin acting as the doer, so an event
// recorded during an impersonated session cannot be pinned on the impersonated
// user alone. Returns nil for ordinary sessions.
func ImpersonatorFromContext(ctx context.Context) *user_model.User {
if impersonator, ok := ctx.Value(impersonatorContextKey).(*user_model.User); ok && impersonator != nil {
return impersonator
}
if data := middleware.GetContextData(ctx); data != nil {
if impersonator, ok := data[middleware.ContextDataKeyImpersonator].(*user_model.User); ok {
return impersonator
}
}
return nil
}
+279
View File
@@ -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"))
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"time"
audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
)
// RecordParams describes an audit event. Callers (or domain-specific helpers)
// supply metadata; the message is rendered from the action's template.
type RecordParams struct {
Action audit_model.Action
Actor audit_model.EntityRef
ActorCredential string
Impersonator *audit_model.EntityRef
Scope audit_model.EntityRef
Metadata map[string]any
}
type originContextKeyType struct{}
var originContextKey originContextKeyType
type requestInfoContextKeyType struct{}
var requestInfoContextKey requestInfoContextKeyType
// requestInfo is what an audit event needs to know about the request it was
// recorded for. The routers publish it, so services don't have to reach for the
// request themselves.
type requestInfo struct {
origin audit_model.Origin
ipAddress string
}
// WithOrigin returns a context that records audit events with the given origin.
// It is for entry points that serve no request, eg: the CLI or a cron task.
func WithOrigin(ctx context.Context, origin audit_model.Origin) context.Context {
return context.WithValue(ctx, originContextKey, origin)
}
// SetRequestInfo attributes the audit events recorded while serving a request
// to the given origin and client address.
func SetRequestInfo(store reqctx.RequestDataStore, origin audit_model.Origin, ipAddress string) {
store.SetContextValue(requestInfoContextKey, &requestInfo{origin: origin, ipAddress: ipAddress})
}
func requestInfoFromContext(ctx context.Context) *requestInfo {
info, _ := ctx.Value(requestInfoContextKey).(*requestInfo)
return info
}
func buildEvent(ctx context.Context, params RecordParams) *audit_model.Event {
e := &audit_model.Event{
Action: params.Action,
ActorID: params.Actor.ID,
ActorName: params.Actor.DisplayName(),
ActorCredential: params.ActorCredential,
ScopeType: params.Scope.Type,
ScopeID: params.Scope.ID,
ScopeName: params.Scope.DisplayName(),
Message: renderMessage(params.Action, params.Actor, params.Scope, params.Metadata),
Metadata: audit_model.EncodeMetadata(params.Metadata),
IPAddress: getIPAddress(ctx),
Origin: getOrigin(ctx),
TimestampUnix: timeutil.TimeStamp(time.Now().Unix()),
}
if params.Impersonator != nil {
e.ImpersonatorID = params.Impersonator.ID
e.ImpersonatorName = params.Impersonator.DisplayName()
}
return e
}
func getIPAddress(ctx context.Context) string {
if info := requestInfoFromContext(ctx); info != nil {
return info.ipAddress
}
return ""
}
func getOrigin(ctx context.Context) audit_model.Origin {
if origin, ok := ctx.Value(originContextKey).(audit_model.Origin); ok && origin != "" {
return origin
}
if info := requestInfoFromContext(ctx); info != nil && info.origin != "" {
return info.origin
}
return audit_model.OriginSystem
}
// Record writes an audit event for an action against a scope entity. The actor
// is the signed-in user of the surrounding request, or whoever audit.WithDoer
// named for a background context.
//
// The scope is the affected entity and may be a *user.User,
// *organization.Organization, *repo.Repository, an EntityRef, or nil for an
// instance-wide/system event. Metadata is supplied as alternating
// string-key/value pairs and fills the placeholders of the action's message
// template, so every key a template names must be passed here.
//
// audit.Record(ctx, audit_model.RepositoryArchive, repo)
// audit.Record(ctx, audit_model.RepositoryDeployKeyAdd, repo, "deploy_key", key.Name)
func Record(ctx context.Context, action audit_model.Action, scope any, metadata ...any) {
RecordAs(ctx, doerFromContext(ctx), action, scope, metadata...)
}
// RecordAs is Record with an explicit actor, for the few call sites where the
// acting user is not the one the context resolves to.
func RecordAs(ctx context.Context, doer *user_model.User, action audit_model.Action, scope any, metadata ...any) {
writeEvent(ctx, RecordParams{
Action: action,
Actor: actorRef(doer),
ActorCredential: actorCredential(ctx, doer),
Impersonator: impersonatorRef(ImpersonatorFromContext(ctx), doer),
Scope: scopeRef(scope),
Metadata: metaPairs(metadata...),
})
}
// writeEvent persists an audit event when audit logging is enabled.
func writeEvent(ctx context.Context, params RecordParams) {
if !setting.AuditRecordEnabled() {
return
}
e := buildEvent(ctx, params)
if err := audit_model.InsertEvent(ctx, e); err != nil {
log.Error("Error writing audit event action=%s actor=%s scope=%s/%d to database: %v", e.Action, e.ActorName, e.ScopeType, e.ScopeID, err)
}
}
func FindEvents(ctx context.Context, opts *audit_model.EventSearchOptions) ([]*audit_model.Event, int64, error) {
return audit_model.FindEvents(ctx, opts)
}
// metaPairs builds caller-defined metadata from alternating string-key/value
// pairs. Keys should be stable for log parsers. A non-string key is skipped and
// logged rather than panicking: audit recording must never crash the request
// that triggered it.
func metaPairs(pairs ...any) map[string]any {
if len(pairs) == 0 {
return nil
}
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
log.Error("audit: metadata key must be string, got %T; skipping pair", pairs[i])
continue
}
m[key] = pairs[i+1]
}
return m
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"io"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/json"
)
// WriteEventsAsJSON writes one JSON object per line.
func WriteEventsAsJSON(w io.Writer, events []*audit_model.Event) error {
for _, event := range events {
b, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := w.Write(append(b, '\n')); err != nil {
return err
}
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"strings"
"testing"
"time"
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/timeutil"
"github.com/stretchr/testify/assert"
)
func TestWriteEventsAsJSON(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"}
doer := &user_model.User{ID: 2, Name: "Doer"}
ctx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
e := buildEvent(ctx, RecordParams{
Action: audit_model.RepositoryMirrorPushAdd,
Actor: actorRef(doer),
Scope: ScopeFromRepository(r),
Metadata: metaPairs(
"mirror_id", m.ID,
"remote_address", m.RemoteAddress,
),
})
e.TimestampUnix = timeutil.TimeStamp(time.Time{}.Unix())
sb := strings.Builder{}
assert.NoError(t, WriteEventsAsJSON(&sb, []*audit_model.Event{e, e}))
out := sb.String()
assert.Equal(t, 2, strings.Count(out, "\n"))
assert.Contains(t, out, `"action":"repository:mirror:push:add"`)
assert.Contains(t, out, `"name":"Doer"`)
assert.Contains(t, out, `"metadata"`)
assert.Contains(t, out, `"remote_address":"git@example.com:repo.git"`)
assert.Contains(t, out, `"ip_address":"127.0.0.1"`)
}
+14
View File
@@ -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)
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"fmt"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/modules/log"
)
// Reserved placeholders, filled from the event itself rather than from metadata.
const (
placeholderScope = "scope"
placeholderActor = "actor"
)
// renderMessage fills the action's template from the event's scope, actor and
// metadata. A missing template or an unresolved placeholder is logged and
// rendered as the bare key: audit recording must never fail the request that
// triggered it, and a partial message is more useful than none.
func renderMessage(action audit_model.Action, actor, scope audit_model.EntityRef, metadata map[string]any) string {
tmpl, ok := audit_model.MessageTemplate(action)
if !ok {
log.Error("audit: no message template for action %q", action)
return string(action)
}
var sb strings.Builder
rest := tmpl
for {
start := strings.IndexByte(rest, '{')
if start < 0 {
break
}
end := strings.IndexByte(rest[start:], '}')
if end < 0 {
break
}
end += start
key := rest[start+1 : end]
sb.WriteString(rest[:start])
sb.WriteString(resolvePlaceholder(action, key, actor, scope, metadata))
rest = rest[end+1:]
}
sb.WriteString(rest)
return sb.String()
}
func resolvePlaceholder(action audit_model.Action, key string, actor, scope audit_model.EntityRef, metadata map[string]any) string {
switch key {
case placeholderScope:
return scope.DisplayName()
case placeholderActor:
return actor.DisplayName()
}
if v, ok := metadata[key]; ok {
return fmt.Sprint(v)
}
log.Error("audit: action %q has no metadata for placeholder %q", action, key)
return key
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
notify_service "gitea.dev/services/notify"
)
func init() {
notify_service.RegisterNotifier(new(auditNotifier))
}
type auditNotifier struct {
notify_service.NullNotifier
}
var _ notify_service.Notifier = new(auditNotifier)
func (n *auditNotifier) CreateRepository(ctx context.Context, doer, _ *user_model.User, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryCreate, repo)
}
func (n *auditNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryCreateFork, repo, "base_repo", oldRepo.FullName())
}
func (n *auditNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
RecordAs(ctx, doer, audit_model.RepositoryName, repo, "previous_name", oldRepoName)
}
func (n *auditNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
RecordAs(ctx, doer, audit_model.RepositoryTransferFinish, repo, "old_owner", oldOwnerName, "new_owner", repo.OwnerName)
}
func (n *auditNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {
RecordAs(ctx, doer, audit_model.RepositoryTransferStart, repo, "new_owner", newOwner.Name)
}
func (n *auditNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
Record(ctx, audit_model.RepositoryBranchDefault, repo, "default_branch", repo.DefaultBranch)
}
func issueLabel(issue *issues_model.Issue) string {
return fmt.Sprintf("#%d", issue.Index)
}
func loadIssueRepo(ctx context.Context, issue *issues_model.Issue) *repo_model.Repository {
if issue.Repo == nil {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("audit: LoadRepo for issue %d: %v", issue.ID, err)
return nil
}
}
return issue.Repo
}
func issueOrPR(issue *issues_model.Issue, issueAction, prAction audit_model.Action) (audit_model.Action, string) {
if issue.IsPull {
return prAction, "pull_request"
}
return issueAction, "issue"
}
// issueMeta keeps the label, ID and title keys of every issue and pull request
// event consistent; the ID is always the issue row ID the label refers to.
func issueMeta(issue *issues_model.Issue, key string) []any {
return []any{key, issueLabel(issue), key + "_id", issue.ID, "title", issue.Title}
}
func (n *auditNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) {
repo := loadIssueRepo(ctx, issue)
if repo == nil {
return
}
RecordAs(ctx, issue.Poster, audit_model.IssueCreate, repo, issueMeta(issue, "issue")...)
}
func (n *auditNotifier) DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
repo := loadIssueRepo(ctx, issue)
if repo == nil {
return
}
action, key := issueOrPR(issue, audit_model.IssueDelete, audit_model.PullRequestDelete)
RecordAs(ctx, doer, action, repo, issueMeta(issue, key)...)
}
func (n *auditNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, _ []*user_model.User) {
if pr.Issue == nil {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
return
}
}
repo := loadIssueRepo(ctx, pr.Issue)
if repo == nil {
return
}
RecordAs(ctx, pr.Issue.Poster, audit_model.PullRequestCreate, repo, issueMeta(pr.Issue, "pull_request")...)
}
func (n *auditNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if pr.Issue == nil {
if err := pr.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
return
}
}
repo := loadIssueRepo(ctx, pr.Issue)
if repo == nil {
return
}
RecordAs(ctx, doer, audit_model.PullRequestMerge, repo, issueMeta(pr.Issue, "pull_request")...)
}
func (n *auditNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User) {
action, key := issueOrPR(issue, audit_model.IssueCommentCreate, audit_model.PullRequestCommentCreate)
RecordAs(ctx, doer, action, repo, key, issueLabel(issue), "comment_id", comment.ID)
}
func (n *auditNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) {
if comment.Issue == nil {
if err := comment.LoadIssue(ctx); err != nil {
log.Error("audit: LoadIssue for comment %d: %v", comment.ID, err)
return
}
}
repo := loadIssueRepo(ctx, comment.Issue)
if repo == nil {
return
}
action, key := issueOrPR(comment.Issue, audit_model.IssueCommentDelete, audit_model.PullRequestCommentDelete)
RecordAs(ctx, doer, action, repo, key, issueLabel(comment.Issue), "comment_id", comment.ID)
}
func (n *auditNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
RecordAs(ctx, doer, audit_model.WikiPageCreate, repo, "page", page)
}
func (n *auditNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
RecordAs(ctx, doer, audit_model.WikiPageUpdate, repo, "page", page)
}
func (n *auditNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) {
RecordAs(ctx, doer, audit_model.WikiPageDelete, repo, "page", page)
}
+81
View File
@@ -0,0 +1,81 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/reqctx"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
func TestAuditNotifier(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
doer := &user_model.User{ID: 2, Name: "doer"}
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
ctx := reqctx.NewRequestContextForTest(t)
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
notifier := new(auditNotifier)
issue := &issues_model.Issue{ID: 10, Index: 5, Title: "Issue title", Poster: doer, Repo: repo}
notifier.NewIssue(ctx, issue, nil)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.IssueCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
pr := &issues_model.PullRequest{ID: 11, Issue: &issues_model.Issue{ID: 12, Index: 6, Title: "PR title", Poster: doer, Repo: repo, IsPull: true}}
notifier.NewPullRequest(ctx, pr, nil)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.PullRequestCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.NewWikiPage(ctx, doer, repo, "Home", "")
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.WikiPageCreate,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.CreateRepository(ctx, doer, doer, repo)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryCreate,
ActorID: doer.ID,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Origin: audit_model.OriginUI,
})
notifier.TransferRepository(ctx, doer, repo, "previous_owner")
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryTransferFinish,
ActorID: doer.ID,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
Message: "Transferred repository owner/repo from previous_owner to owner.",
})
notifier.ChangeDefaultBranch(ctx, repo)
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.RepositoryBranchDefault,
ScopeType: audit_model.ScopeRepository,
ScopeID: repo.ID,
})
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// actorRef builds the actor reference of an event. An unresolvable actor means
// an entry point neither runs inside an authenticated request nor called
// WithDoer; record the event with an "Unknown" actor rather than dropping it,
// and log so the missing entry point is visible.
func actorRef(doer *user_model.User) audit_model.EntityRef {
if doer == nil {
log.Error("audit: no actor in context, recording event as unknown actor")
return audit_model.EntityRef{Type: audit_model.ScopeUser, Name: "Unknown"}
}
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: doer.ID, Name: doer.Name}
}
// actorCredential names what the actor acted with: the task or key behind a
// system user, otherwise the token the surrounding request authenticated with.
// An incident then traces one credential across every event it produced,
// instead of stopping at the account that owns it.
func actorCredential(ctx context.Context, doer *user_model.User) string {
if doer == nil {
return ""
}
if doer.ExtDoerData != nil {
return doer.ExtDoerData.EncodeToString()
}
return credentialFromContext(ctx, doer)
}
// impersonatorRef names the admin behind an impersonated session. It is dropped
// when the actor is the admin themselves, so events an admin performs before
// entering or after leaving an impersonation are not marked as impersonated.
func impersonatorRef(impersonator, doer *user_model.User) *audit_model.EntityRef {
if impersonator == nil || (doer != nil && impersonator.ID == doer.ID) {
return nil
}
return &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: impersonator.ID, Name: impersonator.Name}
}
func ScopeFromUser(u *user_model.User) audit_model.EntityRef {
if u == nil {
return audit_model.EntityRef{}
}
if u.IsOrganization() {
return audit_model.EntityRef{Type: audit_model.ScopeOrganization, ID: u.ID, Name: u.Name}
}
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: u.ID, Name: u.Name}
}
// ScopeFromUserID resolves the scope of a user known only by ID, for call sites
// that would otherwise load the user solely to name it. It costs nothing while
// audit logging is off, and a failed lookup still yields a usable scope so the
// event is never dropped.
func ScopeFromUserID(ctx context.Context, id int64) audit_model.EntityRef {
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: id}
if !setting.AuditRecordEnabled() {
return ref
}
u, err := user_model.GetUserByID(ctx, id)
if err != nil {
log.Error("audit: GetUserByID(%d): %v", id, err)
return ref
}
return ScopeFromUser(u)
}
func ScopeFromRepository(repo *repository_model.Repository) audit_model.EntityRef {
if repo == nil {
return audit_model.EntityRef{}
}
return audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: repo.ID, Name: repo.FullName()}
}
func ScopeSystem() audit_model.EntityRef {
return audit_model.EntityRef{Type: audit_model.ScopeSystem}
}
// scopeRef derives an EntityRef from the affected entity passed to Record.
// Supported types: *user_model.User, *repository_model.Repository, EntityRef,
// or nil for an instance-wide event.
func scopeRef(scope any) audit_model.EntityRef {
switch s := scope.(type) {
case nil:
return ScopeSystem()
case audit_model.EntityRef:
return s
case *user_model.User:
return ScopeFromUser(s)
case *repository_model.Repository:
return ScopeFromRepository(s)
default:
// Audit recording must never crash the request that triggered it; record
// a system-scoped event instead of panicking on an unexpected type.
log.Error("audit: unsupported scope type %T; recording as system scope", scope)
return ScopeSystem()
}
}
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"context"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
)
// ScopedActions holds the action variants for a resource that can be owned by a
// repository, organization, user or the instance itself. RecordScoped selects
// the matching one based on the owner/repo passed at the call site.
type ScopedActions struct {
Repo audit_model.Action
Org audit_model.Action
User audit_model.Action
System audit_model.Action
}
var (
SecretAdd = ScopedActions{
Repo: audit_model.RepositorySecretAdd,
Org: audit_model.OrganizationSecretAdd,
User: audit_model.UserSecretAdd,
}
SecretUpdate = ScopedActions{
Repo: audit_model.RepositorySecretUpdate,
Org: audit_model.OrganizationSecretUpdate,
User: audit_model.UserSecretUpdate,
}
SecretRemove = ScopedActions{
Repo: audit_model.RepositorySecretRemove,
Org: audit_model.OrganizationSecretRemove,
User: audit_model.UserSecretRemove,
}
OAuth2ApplicationAdd = ScopedActions{
User: audit_model.UserOAuth2ApplicationAdd,
Org: audit_model.OrganizationOAuth2ApplicationAdd,
System: audit_model.SystemOAuth2ApplicationAdd,
}
OAuth2ApplicationUpdate = ScopedActions{
User: audit_model.UserOAuth2ApplicationUpdate,
Org: audit_model.OrganizationOAuth2ApplicationUpdate,
System: audit_model.SystemOAuth2ApplicationUpdate,
}
OAuth2ApplicationSecret = ScopedActions{
User: audit_model.UserOAuth2ApplicationSecret,
Org: audit_model.OrganizationOAuth2ApplicationSecret,
System: audit_model.SystemOAuth2ApplicationSecret,
}
OAuth2ApplicationRemove = ScopedActions{
User: audit_model.UserOAuth2ApplicationRemove,
Org: audit_model.OrganizationOAuth2ApplicationRemove,
System: audit_model.SystemOAuth2ApplicationRemove,
}
OAuth2ApplicationRevoke = ScopedActions{
User: audit_model.UserOAuth2ApplicationRevoke,
}
WebhookAdd = ScopedActions{
Repo: audit_model.RepositoryWebhookAdd,
Org: audit_model.OrganizationWebhookAdd,
User: audit_model.UserWebhookAdd,
System: audit_model.SystemWebhookAdd,
}
WebhookUpdate = ScopedActions{
Repo: audit_model.RepositoryWebhookUpdate,
Org: audit_model.OrganizationWebhookUpdate,
User: audit_model.UserWebhookUpdate,
System: audit_model.SystemWebhookUpdate,
}
WebhookRemove = ScopedActions{
Repo: audit_model.RepositoryWebhookRemove,
Org: audit_model.OrganizationWebhookRemove,
User: audit_model.UserWebhookRemove,
System: audit_model.SystemWebhookRemove,
}
)
// resolveScope maps an (owner, repo) pair to the scoped action and audit scope.
// The rules cover every multi-scope resource (secrets, OAuth2 apps, webhooks):
// a repo wins when set, a nil owner means the instance, otherwise the owner's
// kind decides.
func resolveScope(actions ScopedActions, owner *user_model.User, repo *repository_model.Repository) (audit_model.Action, audit_model.EntityRef) {
switch {
case repo != nil:
return actions.Repo, ScopeFromRepository(repo)
case owner == nil:
return actions.System, ScopeSystem()
case owner.IsOrganization():
return actions.Org, ScopeFromUser(owner)
default:
return actions.User, ScopeFromUser(owner)
}
}
// RecordScoped records an audit event for a resource owned by a repository (repo
// set), organization, user, or the instance (owner nil, repo nil). It picks the
// scoped action and scope; each variant carries its own message template, so the
// wording follows automatically. Metadata is supplied as alternating
// string-key/value pairs, like Record.
func RecordScoped(ctx context.Context, owner *user_model.User, repo *repository_model.Repository, actions ScopedActions, metadata ...any) {
action, scope := resolveScope(actions, owner, repo)
if action == "" {
log.Error("audit: no action configured for scope type %s", scope.Type)
return
}
doer := doerFromContext(ctx)
writeEvent(ctx, RecordParams{
Action: action,
Actor: actorRef(doer),
ActorCredential: actorCredential(ctx, doer),
Scope: scope,
Metadata: metaPairs(metadata...),
})
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package audit
import (
"testing"
audit_model "gitea.dev/models/audit"
repository_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
)
func TestResolveScope(t *testing.T) {
actions := WebhookAdd
org := &user_model.User{ID: 10, Name: "MyOrg", Type: user_model.UserTypeOrganization}
usr := &user_model.User{ID: 11, Name: "MyUser", Type: user_model.UserTypeIndividual}
repo := &repository_model.Repository{ID: 12, Name: "repo", OwnerName: "MyOrg"}
t.Run("repo wins over owner", func(t *testing.T) {
action, scope := resolveScope(actions, org, repo)
assert.Equal(t, audit_model.RepositoryWebhookAdd, action)
assert.Equal(t, audit_model.ScopeRepository, scope.Type)
assert.Equal(t, "MyOrg/repo", scope.Name)
})
t.Run("organization owner", func(t *testing.T) {
action, scope := resolveScope(actions, org, nil)
assert.Equal(t, audit_model.OrganizationWebhookAdd, action)
assert.Equal(t, audit_model.ScopeOrganization, scope.Type)
assert.Equal(t, "MyOrg", scope.Name)
})
t.Run("user owner", func(t *testing.T) {
action, scope := resolveScope(actions, usr, nil)
assert.Equal(t, audit_model.UserWebhookAdd, action)
assert.Equal(t, audit_model.ScopeUser, scope.Type)
assert.Equal(t, "MyUser", scope.Name)
})
t.Run("system when no owner and no repo", func(t *testing.T) {
action, scope := resolveScope(actions, nil, nil)
assert.Equal(t, audit_model.SystemWebhookAdd, action)
assert.Equal(t, audit_model.ScopeSystem, scope.Type)
})
}
// Audit recording must never crash the request that triggered it.
func TestRecordHelpersNeverPanic(t *testing.T) {
t.Run("metaPairs skips non-string keys", func(t *testing.T) {
var m map[string]any
assert.NotPanics(t, func() {
m = metaPairs("ok", 1, 42 /* bad key */, "value", "second", 2)
})
assert.Equal(t, 1, m["ok"])
assert.Equal(t, 2, m["second"])
assert.Len(t, m, 2) // the pair with the non-string key is dropped
})
t.Run("scopeRef falls back to system on unsupported type", func(t *testing.T) {
var ref audit_model.EntityRef
assert.NotPanics(t, func() {
ref = scopeRef(struct{ Foo string }{Foo: "bar"})
})
assert.Equal(t, audit_model.ScopeSystem, ref.Type)
})
}
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"net/http"
actions_model "gitea.dev/models/actions"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/httpauth"
@@ -16,6 +17,7 @@ import (
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
// Ensure the struct implements the interface.
@@ -71,7 +73,7 @@ func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd str
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
// get oauth2 token's user's ID
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
if uid != 0 {
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
@@ -83,6 +85,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
return u, nil
}
@@ -103,6 +106,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
store.GetData()["LoginMethod"] = AccessTokenMethodName
store.GetData()["ApiTokenScope"] = token.Scope
setAuthCredential(store, credentialAccessToken, token.ID)
return u, nil
} else if !errors.Is(err, util.ErrNotExist) {
log.Error("GetAccessTokenBySHA: %v", err)
@@ -180,6 +184,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
return err
} else if !ok {
audit.RecordAs(req.Context(), u, audit_model.UserAuthenticationFailTwoFactor, u)
return util.NewInvalidArgumentErrorf("invalid provided OTP")
}
return nil
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"strconv"
"gitea.dev/modules/web/middleware"
)
// Credential kinds naming how a request authenticated, recorded so an audit
// event can point at the token rather than only at its owner.
const (
credentialAccessToken = "access-token"
credentialOAuth2Grant = "oauth2-grant"
)
func setAuthCredential(store DataStore, kind string, id int64) {
store.GetData()[middleware.ContextDataKeyAuthCredential] = kind + ":" + strconv.FormatInt(id, 10)
}
+11
View File
@@ -42,6 +42,17 @@ func ImpersonateUser(sess SessionStore, u *user_model.User) error {
return sess.Release()
}
// ImpersonatorUserID returns the ID of the admin behind an impersonated
// session, or zero when the session is not impersonating anyone.
func ImpersonatorUserID(sess SessionStore) int64 {
data, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
if !ok {
return 0
}
uid, _ := data[session.KeyUID].(int64)
return uid
}
func ExitImpersonatedUser(sess SessionStore) (bool, error) {
impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
if !ok {
+13 -10
View File
@@ -25,35 +25,36 @@ import (
var _ Method = &OAuth2{}
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
// GetOAuthAccessTokenScopeAndUserID returns access token scope, user id and the
// grant the token was issued for.
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (_ auth_model.AccessTokenScope, userID, grantID int64) {
var accessTokenScope auth_model.AccessTokenScope
if !setting.OAuth2.Enabled {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
// JWT tokens require a ".", if the token isn't like that, return early
if !strings.Contains(accessToken, ".") {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
if err != nil {
log.Trace("oauth2.ParseToken: %v", err)
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
var grant *auth_model.OAuth2Grant
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
if token.Kind != oauth2_provider.KindAccessToken {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
return accessTokenScope, 0
return accessTokenScope, 0, 0
}
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
return accessTokenScope, grant.UserID
return accessTokenScope, grant.UserID, grant.ID
}
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
@@ -118,9 +119,10 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
}
// Otherwise, check if this is an OAuth access token
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
if uid != 0 {
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
}
return user_model.GetUserByID(ctx, uid)
}
@@ -141,6 +143,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
log.Error("UpdateAccessToken: %v", err)
}
store.GetData()["ApiTokenScope"] = t.Scope
setAuthCredential(store, credentialAccessToken, t.ID)
return user_model.GetUserByID(ctx, t.UID)
}
+4
View File
@@ -9,11 +9,13 @@ import (
"strings"
"uuid"
audit_model "gitea.dev/models/audit"
user_model "gitea.dev/models/user"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/session"
"gitea.dev/modules/setting"
"gitea.dev/services/audit"
)
// Ensure the struct implements the interface.
@@ -171,5 +173,7 @@ func (r *ReverseProxy) newUser(req *http.Request) *user_model.User {
return nil
}
audit.RecordAs(req.Context(), user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user
}
+33 -2
View File
@@ -6,11 +6,37 @@ package auth
import (
"context"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
)
// CreateSource creates a AuthSource record in DB.
func CreateSource(ctx context.Context, source *auth.Source) error {
if err := auth.CreateSource(ctx, source); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceAdd, nil,
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
return nil
}
// UpdateSource updates a AuthSource record in DB.
func UpdateSource(ctx context.Context, source *auth.Source) error {
if err := auth.UpdateSource(ctx, source); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceUpdate, nil,
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
return nil
}
// DeleteSource deletes a AuthSource record in DB.
func DeleteSource(ctx context.Context, source *auth.Source) error {
count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
@@ -37,6 +63,11 @@ func DeleteSource(ctx context.Context, source *auth.Source) error {
}
}
_, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
return err
if _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)); err != nil {
return err
}
audit.Record(ctx, audit_model.SystemAuthenticationSourceRemove, nil, "auth_source", source.Name)
return nil
}
@@ -8,12 +8,14 @@ import (
"strings"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
auth_module "gitea.dev/modules/auth"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
source_service "gitea.dev/services/auth/source"
user_service "gitea.dev/services/user"
)
@@ -21,6 +23,8 @@ import (
// Authenticate queries if login/password is valid against the LDAP directory pool,
// and create a local user if success when enabled.
func (source *Source) Authenticate(ctx context.Context, user *user_model.User, userName, password string) (*user_model.User, error) {
ctx = audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser())
loginName := userName
if user != nil {
loginName = user.LoginName
@@ -99,6 +103,8 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
audit.Record(ctx, audit_model.UserCreate, user)
if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.AuthSource, sr.SSHPublicKey, source.SSHKeysAreVerified) {
if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil {
return user, err
+8
View File
@@ -8,6 +8,7 @@ import (
"strings"
asymkey_model "gitea.dev/models/asymkey"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
"gitea.dev/models/organization"
user_model "gitea.dev/models/user"
@@ -16,6 +17,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
source_service "gitea.dev/services/auth/source"
user_service "gitea.dev/services/user"
)
@@ -24,6 +26,10 @@ import (
func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
log.Trace("Doing: SyncExternalUsers[%s]", source.AuthSource.Name)
// everything this sync changes is attributed to the authentication source,
// not to a signed-in user
ctx = audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser())
isAttributeSSHPublicKeySet := strings.TrimSpace(source.AttributeSSHPublicKey) != ""
var sshKeysNeedUpdate bool
@@ -131,6 +137,8 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error {
err = user_model.CreateUser(ctx, usr, &user_model.Meta{}, overwriteDefault)
if err != nil {
log.Error("SyncExternalUsers[%s]: Error creating user %s: %v", source.AuthSource.Name, su.Username, err)
} else {
audit.Record(ctx, audit_model.UserCreate, usr)
}
if err == nil && isAttributeSSHPublicKeySet {
@@ -9,11 +9,13 @@ import (
"strings"
"uuid"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/auth/pam"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/services/audit"
)
// Authenticate queries if login/password is valid against the PAM,
@@ -66,5 +68,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
audit.RecordAs(ctx, user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user, nil
}
@@ -10,10 +10,12 @@ import (
"net/textproto"
"strings"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
// Authenticate queries if the provided login/password is authenticates against the SMTP server
@@ -83,5 +85,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u
return user, err
}
audit.RecordAs(ctx, user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user, nil
}
@@ -12,6 +12,7 @@ import (
user_model "gitea.dev/models/user"
"gitea.dev/modules/container"
"gitea.dev/modules/log"
"gitea.dev/services/audit"
org_service "gitea.dev/services/org"
)
@@ -31,6 +32,9 @@ func SyncGroupsToTeams(ctx context.Context, user *user_model.User, sourceUserGro
// SyncGroupsToTeamsCached maps authentication source groups to organization and team memberships
func SyncGroupsToTeamsCached(ctx context.Context, user *user_model.User, sourceUserGroups container.Set[string], sourceGroupTeamMapping map[string]map[string][]string, performRemoval bool, orgCache map[string]*organization.Organization, teamCache map[string]*organization.Team) error {
// team membership changes here come from the authentication source mapping
ctx = audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser())
membershipsToAdd, membershipsToRemove := resolveMappedMemberships(sourceUserGroups, sourceGroupTeamMapping)
if performRemoval {
+4
View File
@@ -11,6 +11,7 @@ import (
"sync"
"uuid"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/auth"
"gitea.dev/models/db"
user_model "gitea.dev/models/user"
@@ -18,6 +19,7 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/templates"
"gitea.dev/services/audit"
"gitea.dev/services/auth/source/sspi"
gitea_context "gitea.dev/services/context"
)
@@ -171,6 +173,8 @@ func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) (
return nil, err
}
audit.RecordAs(ctx, user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
return user, nil
}
+3 -6
View File
@@ -5,7 +5,6 @@ package context
import (
"bytes"
"net"
"net/http"
"strings"
"text/template"
@@ -13,6 +12,7 @@ import (
"unicode"
user_model "gitea.dev/models/user"
"gitea.dev/modules/httplib"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/web/middleware"
@@ -77,10 +77,7 @@ func (lr *accessLogRecorder) record(start time.Time, respWriter ResponseWriter,
requestID = parseRequestIDFromRequestHeader(req)
}
reqHost, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
reqHost = req.RemoteAddr
}
reqHost := httplib.RemoteHost(req)
identity := "-"
data := middleware.GetContextData(req.Context())
@@ -100,7 +97,7 @@ func (lr *accessLogRecorder) record(start time.Time, respWriter ResponseWriter,
}
tmplData.ResponseWriter.Status = respWriter.WrittenStatus()
tmplData.ResponseWriter.Size = respWriter.WrittenSize()
err = lr.logTemplate.Execute(buf, tmplData)
err := lr.logTemplate.Execute(buf, tmplData)
if err != nil {
log.Error("Could not execute access logger template: %v", err.Error())
}
+22 -2
View File
@@ -8,6 +8,7 @@ import (
"time"
activities_model "gitea.dev/models/activities"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/system"
user_model "gitea.dev/models/user"
"gitea.dev/modules/git/gitcmd"
@@ -15,6 +16,7 @@ import (
"gitea.dev/modules/setting"
"gitea.dev/modules/updatechecker"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
repo_service "gitea.dev/services/repository"
archiver_service "gitea.dev/services/repository/archiver"
user_service "gitea.dev/services/user"
@@ -28,8 +30,8 @@ func registerDeleteInactiveUsers() {
Schedule: "@annually",
},
OlderThan: time.Minute * time.Duration(setting.Service.ActiveCodeLives),
}, func(ctx context.Context, _ *user_model.User, config *OlderThanConfig) error {
return user_service.DeleteInactiveUsers(ctx, config.OlderThan)
}, func(ctx context.Context, doer *user_model.User, config *OlderThanConfig) error {
return user_service.DeleteInactiveUsers(audit.WithDoer(ctx, doer), config.OlderThan)
})
}
@@ -136,6 +138,23 @@ func registerDeleteOldActions() {
})
}
func registerDeleteOldAuditEvents() {
if !setting.AuditRecordEnabled() || setting.AuditRetentionPeriod() <= 0 {
return
}
RegisterTaskFatal("delete_old_audit_events", &OlderThanConfig{
BaseConfig: BaseConfig{
Enabled: true,
RunAtStart: false,
Schedule: "@every 24h",
},
OlderThan: setting.AuditRetentionPeriod(),
}, func(ctx context.Context, _ *user_model.User, config *OlderThanConfig) error {
return audit_model.DeleteOldEvents(ctx, config.OlderThan)
})
}
func registerUpdateGiteaChecker() {
type UpdateCheckerConfig struct {
BaseConfig
@@ -229,6 +248,7 @@ func initExtendedTasks() {
registerDeleteMissingRepositories()
registerRemoveRandomAvatars()
registerDeleteOldActions()
registerDeleteOldAuditEvents()
registerUpdateGiteaChecker()
registerDeleteOldSystemNotices()
registerGCLFS()
+5
View File
@@ -8,10 +8,12 @@ import (
"strconv"
"strings"
audit_model "gitea.dev/models/audit"
issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/modules/structs"
"gitea.dev/services/audit"
"github.com/markbates/goth"
)
@@ -46,6 +48,9 @@ func LinkAccountToUser(ctx context.Context, authSourceID int64, user *user_model
return err
}
audit.RecordAs(ctx, user, audit_model.UserExternalLoginAdd, user,
"external_id", externalLoginUser.ExternalID, "provider", externalLoginUser.Provider)
externalID := externalLoginUser.ExternalID
var tp structs.GitServiceType
+14 -2
View File
@@ -9,6 +9,7 @@ import (
actions_model "gitea.dev/models/actions"
activities_model "gitea.dev/models/activities"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
org_model "gitea.dev/models/organization"
packages_model "gitea.dev/models/packages"
@@ -21,6 +22,7 @@ import (
"gitea.dev/modules/storage"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
repo_service "gitea.dev/services/repository"
)
@@ -85,6 +87,8 @@ func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge
return err
}
audit.Record(ctx, audit_model.OrganizationDelete, org.AsUser())
// FIXME: system notice
// Note: There are something just cannot be roll back,
// so just keep error logs of those operations.
@@ -158,9 +162,10 @@ func ChangeOrganizationVisibility(ctx context.Context, org *org_model.Organizati
return nil
}
oldVisibility := org.Visibility
org.Visibility = visibility
// FIXME: If it's a big forks network(forks and sub forks), the database transaction will be too long to fail.
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := user_model.UpdateUserColsNoAutoTime(ctx, org.AsUser(), "visibility"); err != nil {
return err
}
@@ -177,7 +182,14 @@ func ChangeOrganizationVisibility(ctx context.Context, org *org_model.Organizati
}
}
return nil
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.OrganizationVisibility, org.AsUser(),
"old_visibility", oldVisibility.String(), "new_visibility", visibility.String())
return nil
}
// UpdateOrgEmailAddress validates and updates the organization's contact email.
+50 -8
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"strings"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
@@ -20,11 +21,23 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
repo_service "gitea.dev/services/repository"
"xorm.io/builder"
)
// recordTeamAudit emits a team-related audit event scoped to the owning organization.
func recordTeamAudit(ctx context.Context, action audit_model.Action, team *organization.Team, metadata ...any) {
audit.Record(ctx, action, audit.ScopeFromUserID(ctx, team.OrgID), metadata...)
}
// recordTeamMemberAudit emits a team membership audit event scoped to the
// owning organization.
func recordTeamMemberAudit(ctx context.Context, action audit_model.Action, team *organization.Team, member *user_model.User) {
recordTeamAudit(ctx, action, team, "team", team.Name, "member", member.Name)
}
// NewTeam creates a record of new team.
// It's caller's responsibility to assign organization ID.
func NewTeam(ctx context.Context, t *organization.Team) (err error) {
@@ -56,7 +69,7 @@ func NewTeam(ctx context.Context, t *organization.Team) (err error) {
return organization.ErrTeamAlreadyExist{OrgID: t.OrgID, Name: t.LowerName}
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err = db.WithTx(ctx, func(ctx context.Context) error {
if err = db.Insert(ctx, t); err != nil {
return err
}
@@ -82,7 +95,13 @@ func NewTeam(ctx context.Context, t *organization.Team) (err error) {
// Update organization number of teams.
_, err = db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams+1 WHERE id = ?", t.OrgID)
return err
})
}); err != nil {
return err
}
recordTeamAudit(ctx, audit_model.OrganizationTeamAdd, t, "team", t.Name)
return nil
}
// UpdateTeam updates information of team.
@@ -95,7 +114,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA
t.Description = t.Description[:255]
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err = db.WithTx(ctx, func(ctx context.Context) error {
t.LowerName = strings.ToLower(t.Name)
has, err := db.Exist[organization.Team](ctx, builder.Eq{
"org_id": t.OrgID,
@@ -155,13 +174,22 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA
}
return nil
})
}); err != nil {
return err
}
recordTeamAudit(ctx, audit_model.OrganizationTeamUpdate, t, "team", t.Name)
if authChanged {
recordTeamAudit(ctx, audit_model.OrganizationTeamPermission, t, "team", t.Name, "permission", t.AccessMode.ToString())
}
return nil
}
// DeleteTeam deletes given team.
// It's caller's responsibility to assign organization ID.
func DeleteTeam(ctx context.Context, t *organization.Team) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := t.LoadMembers(ctx); err != nil {
return err
}
@@ -205,7 +233,13 @@ func DeleteTeam(ctx context.Context, t *organization.Team) error {
// Update organization number of teams.
_, err := db.Exec(ctx, "UPDATE `user` SET num_teams=num_teams-1 WHERE id=?", t.OrgID)
return err
})
}); err != nil {
return err
}
recordTeamAudit(ctx, audit_model.OrganizationTeamRemove, t, "team", t.Name)
return nil
}
// AddTeamMember adds new membership of given team to given organization,
@@ -250,6 +284,8 @@ func AddTeamMember(ctx context.Context, team *organization.Team, user *user_mode
return err
}
recordTeamMemberAudit(ctx, audit_model.OrganizationTeamMemberAdd, team, user)
// this behaviour may spend much time so run it in a goroutine
// FIXME: Update watch repos batchly
if setting.Service.AutoWatchNewRepos {
@@ -347,7 +383,13 @@ func removeInvalidOrgUser(ctx context.Context, orgID int64, user *user_model.Use
// RemoveTeamMember removes member from given team of given organization.
func RemoveTeamMember(ctx context.Context, team *organization.Team, user *user_model.User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
return removeTeamMember(ctx, team, user)
})
}); err != nil {
return err
}
recordTeamMemberAudit(ctx, audit_model.OrganizationTeamMemberRemove, team, user)
return nil
}
+9 -2
View File
@@ -8,11 +8,13 @@ import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
"gitea.dev/models/organization"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
)
// RemoveOrgUser removes user from given organization.
@@ -47,7 +49,7 @@ func RemoveOrgUser(ctx context.Context, org *organization.Organization, user *us
}
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if _, err := db.DeleteByID[organization.OrgUser](ctx, ou.ID); err != nil {
return err
} else if _, err = db.Exec(ctx, "UPDATE `user` SET num_members=num_members-1 WHERE id=?", org.ID); err != nil {
@@ -94,5 +96,10 @@ func RemoveOrgUser(ctx context.Context, org *organization.Organization, user *us
}
}
return nil
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.OrganizationMemberRemove, audit.ScopeFromUserID(ctx, org.ID), "member", user.Name)
return nil
}
+20
View File
@@ -6,9 +6,12 @@ package org
import (
"testing"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/organization"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
"gitea.dev/modules/setting"
"gitea.dev/modules/test"
"github.com/stretchr/testify/assert"
)
@@ -71,3 +74,20 @@ func TestRemoveOrgUser(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{OrgID: org7.ID, UID: user5.ID})
unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{})
}
func TestRemoveOrgUserRecordsAudit(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
unittest.AssertExistsAndLoadBean(t, &organization.OrgUser{OrgID: org.ID, UID: user.ID})
assert.NoError(t, RemoveOrgUser(t.Context(), org, user))
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
Action: audit_model.OrganizationMemberRemove,
ScopeType: audit_model.ScopeOrganization,
ScopeID: org.ID,
})
}
+40 -12
View File
@@ -8,12 +8,14 @@ import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/perm"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
"gitea.dev/services/audit"
"xorm.io/builder"
)
@@ -34,7 +36,8 @@ func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u
return user_model.ErrBlockedUser
}
return db.WithTx(ctx, func(ctx context.Context) error {
added, updated := false, false
if err := db.WithTx(ctx, func(ctx context.Context) error {
collaboration, has, err := db.Get[repo_model.Collaboration](ctx, builder.Eq{
"repo_id": repo.ID,
"user_id": u.ID,
@@ -54,16 +57,31 @@ func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u
}); err != nil {
return err
}
} else if err = db.Insert(ctx, &repo_model.Collaboration{
RepoID: repo.ID,
UserID: u.ID,
Mode: mode,
}); err != nil {
return err
updated = true
} else {
if err = db.Insert(ctx, &repo_model.Collaboration{
RepoID: repo.ID,
UserID: u.ID,
Mode: mode,
}); err != nil {
return err
}
added = true
}
return access_model.RecalculateUserAccess(ctx, repo, u.ID)
})
}); err != nil {
return err
}
switch {
case added:
audit.Record(ctx, audit_model.RepositoryCollaboratorAdd, repo, "collaborator", u.Name, "access_mode", mode.ToString())
case updated:
audit.Record(ctx, audit_model.RepositoryCollaboratorAccess, repo, "collaborator", u.Name, "access_mode", mode.ToString())
}
return nil
}
// DeleteCollaboration removes collaboration relation between the user and repository.
@@ -78,12 +96,14 @@ func deleteCollaborationByMode(ctx context.Context, repo *repo_model.Repository,
}
func deleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User, collaboration *repo_model.Collaboration) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
if deleted, err := db.GetEngine(ctx).Delete(collaboration); err != nil {
deleted := false
if err := db.WithTx(ctx, func(ctx context.Context) error {
if n, err := db.GetEngine(ctx).Delete(collaboration); err != nil {
return err
} else if deleted == 0 {
} else if n == 0 {
return nil
}
deleted = true
if err := repo.LoadOwner(ctx); err != nil {
return err
@@ -98,7 +118,15 @@ func deleteCollaboration(ctx context.Context, repo *repo_model.Repository, colla
// Unassign a user from any issue (s)he has been assigned to in the repository
return ReconsiderRepoIssuesAssignee(ctx, repo, collaborator)
})
}); err != nil {
return err
}
if deleted {
audit.Record(ctx, audit_model.RepositoryCollaboratorRemove, repo, "collaborator", collaborator.Name)
}
return nil
}
func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error {
+10 -2
View File
@@ -9,6 +9,7 @@ import (
"strings"
"time"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
repo_model "gitea.dev/models/repo"
@@ -19,6 +20,7 @@ import (
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
notify_service "gitea.dev/services/notify"
"xorm.io/builder"
@@ -210,7 +212,7 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
// ConvertForkToNormalRepository convert the provided repo from a forked repo to normal repo
func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Repository) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
repo, err := repo_model.GetRepositoryByID(ctx, repo.ID)
if err != nil {
return err
@@ -228,7 +230,13 @@ func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Reposit
repo.IsFork = false
repo.ForkID = 0
return repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_fork", "fork_id")
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryConvertFork, repo)
return nil
}
type findForksOptions struct {
+47 -9
View File
@@ -8,25 +8,33 @@ import (
"errors"
"fmt"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
access_model "gitea.dev/models/perm/access"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/setting"
"gitea.dev/services/audit"
)
// TeamAddRepository adds new repository to team of organization.
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
func TeamAddRepository(ctx context.Context, t *organization.Team, repo *repo_model.Repository) error {
if repo.OwnerID != t.OrgID {
return errors.New("repository does not belong to organization")
} else if organization.HasTeamRepo(ctx, t.OrgID, t.ID, repo.ID) {
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
return addRepositoryToTeam(ctx, t, repo)
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryCollaboratorTeamAdd, repo, "team", t.Name)
return nil
}
func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) {
@@ -62,7 +70,8 @@ func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_m
// AddAllRepositoriesToTeam adds all repositories to the team.
// If the team already has some repositories they will be left unchanged.
func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) error {
return db.WithTx(ctx, func(ctx context.Context) error {
added := make([]*repo_model.Repository, 0, 5)
if err := db.WithTx(ctx, func(ctx context.Context) error {
orgRepos, err := repo_model.GetOrgRepositories(ctx, t.OrgID)
if err != nil {
return fmt.Errorf("get org repos: %w", err)
@@ -73,11 +82,20 @@ func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) error {
if err := addRepositoryToTeam(ctx, t, repo); err != nil {
return fmt.Errorf("AddRepository: %w", err)
}
added = append(added, repo)
}
}
return nil
})
}); err != nil {
return err
}
for _, repo := range added {
audit.Record(ctx, audit_model.RepositoryCollaboratorTeamAdd, repo, "team", t.Name)
}
return nil
}
// RemoveAllRepositoriesFromTeam removes all repositories from team and recalculates access
@@ -86,9 +104,23 @@ func RemoveAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (e
return nil
}
return db.WithTx(ctx, func(ctx context.Context) error {
var removed repo_model.RepositoryList
if err := db.WithTx(ctx, func(ctx context.Context) error {
var err error
removed, err = repo_model.GetTeamRepositories(ctx, &repo_model.SearchTeamRepoOptions{TeamID: t.ID})
if err != nil {
return fmt.Errorf("GetTeamRepositories: %w", err)
}
return removeAllRepositoriesFromTeam(ctx, t)
})
}); err != nil {
return err
}
for _, repo := range removed {
audit.Record(ctx, audit_model.RepositoryCollaboratorTeamRemove, repo, "team", t.Name)
}
return nil
}
// removeAllRepositoriesFromTeam removes all repositories from team and recalculates access
@@ -159,9 +191,15 @@ func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
return removeRepositoryFromTeam(ctx, t, repo, true)
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryCollaboratorTeamRemove, repo, "team", t.Name)
return nil
}
// removeRepositoryFromTeam removes a repository from a team and recalculates access
+21 -3
View File
@@ -10,6 +10,7 @@ import (
"strings"
activities_model "gitea.dev/models/activities"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
@@ -27,6 +28,7 @@ import (
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/services/audit"
notify_service "gitea.dev/services/notify"
pull_service "gitea.dev/services/pull"
)
@@ -68,7 +70,13 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod
notify_service.DeleteRepository(ctx, doer, repo)
}
return DeleteRepositoryDirectly(ctx, repo.ID)
if err := DeleteRepositoryDirectly(ctx, repo.ID); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryDelete, repo)
return nil
}
// PushCreateRepo creates a repository when a new repository is pushed to an appropriate namespace
@@ -127,6 +135,16 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili
}
func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository, private bool) (err error) {
if err := setRepoVisibility(ctx, repo, private); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryVisibility, repo, "visibility", private)
return nil
}
func setRepoVisibility(ctx context.Context, repo *repo_model.Repository, private bool) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
repo.IsPrivate = private
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
@@ -173,8 +191,8 @@ func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository, private b
return fmt.Errorf("getRepositoriesByForkID: %w", err)
}
for _, forkRepo := range forkRepos {
if err = MakeRepoPrivate(ctx, forkRepo, private); err != nil {
return fmt.Errorf("MakeRepoPrivate[%d]: %w", forkRepo.ID, err)
if err = setRepoVisibility(ctx, forkRepo, private); err != nil {
return fmt.Errorf("setRepoVisibility[%d]: %w", forkRepo.ID, err)
}
}
}
+28 -5
View File
@@ -9,6 +9,7 @@ import (
"strings"
actions_model "gitea.dev/models/actions"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
"gitea.dev/models/organization"
@@ -22,6 +23,7 @@ import (
"gitea.dev/modules/globallock"
"gitea.dev/modules/log"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
notify_service "gitea.dev/services/notify"
)
@@ -352,7 +354,15 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName
}
}
return committer.Commit()
if err := committer.Commit(); err != nil {
return err
}
for _, team := range teams {
audit.Record(ctx, audit_model.RepositoryCollaboratorTeamAdd, newRepo, "team", team.Name)
}
return nil
}
// changeRepositoryName changes all corresponding setting from old repository name to new one.
@@ -417,6 +427,7 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo
releaser()
repo.Name = newRepoName
notify_service.RenameRepository(ctx, doer, repo, oldRepoName)
return nil
@@ -502,7 +513,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
// thus cancel the transfer process.
// The accepter can reject the transfer.
func RejectRepositoryTransfer(ctx context.Context, repo *repo_model.Repository, doer *user_model.User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, repo)
if err != nil {
return err
@@ -525,7 +536,13 @@ func RejectRepositoryTransfer(ctx context.Context, repo *repo_model.Repository,
}
return repo_model.DeleteRepositoryTransfer(ctx, repo.ID)
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryTransferCancel, repo)
return nil
}
func removeTransferRecipientCollaboration(ctx context.Context, repoTransfer *repo_model.RepoTransfer) error {
@@ -565,7 +582,7 @@ func canUserCancelTransfer(ctx context.Context, r *repo_model.RepoTransfer, u *u
// CancelRepositoryTransfer cancels the repository transfer process. The sender or
// the users who have admin permission of the original repository can cancel the transfer
func CancelRepositoryTransfer(ctx context.Context, repoTransfer *repo_model.RepoTransfer, doer *user_model.User) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := repoTransfer.LoadAttributes(ctx); err != nil {
return err
}
@@ -583,5 +600,11 @@ func CancelRepositoryTransfer(ctx context.Context, repoTransfer *repo_model.Repo
}
return repo_model.DeleteRepositoryTransfer(ctx, repoTransfer.RepoID)
})
}); err != nil {
return err
}
audit.Record(ctx, audit_model.RepositoryTransferCancel, repoTransfer.Repo)
return nil
}
+8 -8
View File
@@ -39,36 +39,36 @@ func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data
return s[0], false, nil
}
func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error {
func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) (*secret_model.Secret, error) {
s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{
OwnerID: ownerID,
RepoID: repoID,
SecretID: secretID,
})
if err != nil {
return err
return nil, err
}
if len(s) != 1 {
return secret_model.ErrSecretNotFound{}
return nil, secret_model.ErrSecretNotFound{}
}
return deleteSecret(ctx, s[0])
return s[0], deleteSecret(ctx, s[0])
}
func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) error {
func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) (*secret_model.Secret, error) {
s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{
OwnerID: ownerID,
RepoID: repoID,
Name: name,
})
if err != nil {
return err
return nil, err
}
if len(s) != 1 {
return secret_model.ErrSecretNotFound{}
return nil, secret_model.ErrSecretNotFound{}
}
return deleteSecret(ctx, s[0])
return s[0], deleteSecret(ctx, s[0])
}
func deleteSecret(ctx context.Context, s *secret_model.Secret) error {
+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) {
+4
View File
@@ -8,6 +8,7 @@ import (
"context"
"fmt"
audit_model "gitea.dev/models/audit"
"gitea.dev/models/db"
repo_model "gitea.dev/models/repo"
system_model "gitea.dev/models/system"
@@ -21,6 +22,7 @@ import (
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/util"
asymkey_service "gitea.dev/services/asymkey"
"gitea.dev/services/audit"
repo_service "gitea.dev/services/repository"
)
@@ -375,6 +377,8 @@ func DeleteWiki(ctx context.Context, repo *repo_model.Repository) error {
}
}
audit.Record(ctx, audit_model.RepositoryWikiDelete, repo)
return nil
}