mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-14 19:22:09 +09:00
feat: Add audit logging (#38189)
Co-authored-by: bircni <bircni@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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...),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user