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:
@@ -702,18 +702,7 @@ jobs:
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, addFileResp)
|
||||
sha = addFileResp.Commit.SHA
|
||||
assert.Eventually(t, func() bool {
|
||||
latestCommitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, sha, db.ListOptionsAll)
|
||||
assert.NoError(t, err)
|
||||
if len(latestCommitStatuses) == 0 {
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, 1*time.Second, 100*time.Millisecond)
|
||||
waitCommitStatusAndInsertFakeStatus(t, repo, sha)
|
||||
|
||||
// milestoned
|
||||
milestone := &issues_model.Milestone{
|
||||
@@ -754,6 +743,18 @@ func checkCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Reposit
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
}
|
||||
|
||||
// waitCommitStatusAndInsertFakeStatus is the async counterpart of
|
||||
// checkCommitStatusAndInsertFakeStatus, for events whose run is created off the
|
||||
// request path (push/review) and may lag behind on a loaded CI runner.
|
||||
func waitCommitStatusAndInsertFakeStatus(t *testing.T, repo *repo_model.Repository, sha string) {
|
||||
t.Helper()
|
||||
require.Eventually(t, func() bool {
|
||||
latestCommitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, sha, db.ListOptionsAll)
|
||||
return err == nil && len(latestCommitStatuses) == 1 && latestCommitStatuses[0].State == commitstatus.CommitStatusPending
|
||||
}, 10*time.Second, 50*time.Millisecond, "no pending commit status for commit %s", sha)
|
||||
checkCommitStatusAndInsertFakeStatus(t, repo, sha)
|
||||
}
|
||||
|
||||
// insertFakeStatus inserts a success status that lands in the same dedupe
|
||||
// group as `prev` — the actions runner mixes the workflow file path into
|
||||
// ContextHash, so we must reuse it (rather than recomputing from Context).
|
||||
@@ -889,18 +890,7 @@ jobs:
|
||||
assert.NoError(t, err)
|
||||
|
||||
// verify that a commit status was created for the review event
|
||||
assert.Eventually(t, func() bool {
|
||||
latestCommitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo.ID, sha, db.ListOptionsAll)
|
||||
assert.NoError(t, err)
|
||||
if len(latestCommitStatuses) == 0 {
|
||||
return false
|
||||
}
|
||||
if latestCommitStatuses[0].State == commitstatus.CommitStatusPending {
|
||||
insertFakeStatus(t, repo, sha, latestCommitStatuses[0])
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, 1*time.Second, 100*time.Millisecond)
|
||||
waitCommitStatusAndInsertFakeStatus(t, repo, sha)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
audit_model "gitea.dev/models/audit"
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Events an admin causes while impersonating must stay traceable to the admin,
|
||||
// otherwise anything done in an impersonated session is pinned on the victim.
|
||||
func TestAdminAuditLogImpersonation(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
|
||||
|
||||
session := loginUser(t, "user1")
|
||||
session.MakeRequest(t, NewRequest(t, "POST", "/-/admin/users/2/impersonate"), http.StatusOK)
|
||||
|
||||
session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/settings/applications", map[string]string{
|
||||
"name": "impersonated-token",
|
||||
"scope-dummy": "read:user",
|
||||
}), http.StatusSeeOther)
|
||||
|
||||
session.MakeRequest(t, NewRequest(t, "GET", "/user/logout"), http.StatusSeeOther)
|
||||
|
||||
events, _, err := audit_model.FindEvents(t.Context(), &audit_model.EventSearchOptions{ActorID: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
byAction := make(map[audit_model.Action]*audit_model.Event, len(events))
|
||||
for _, e := range events {
|
||||
byAction[e.Action] = e
|
||||
}
|
||||
|
||||
start := byAction[audit_model.UserImpersonation]
|
||||
require.NotNil(t, start)
|
||||
assert.Equal(t, int64(1), start.ActorID)
|
||||
assert.Equal(t, int64(2), start.ScopeID)
|
||||
|
||||
token := byAction[audit_model.UserAccessTokenAdd]
|
||||
require.NotNil(t, token)
|
||||
assert.Equal(t, int64(2), token.ActorID) // the token really belongs to user2
|
||||
assert.Equal(t, int64(1), token.ImpersonatorID)
|
||||
assert.Equal(t, "user1", token.ImpersonatorName)
|
||||
assert.Equal(t, audit_model.OriginUI, token.Origin)
|
||||
assert.NotEmpty(t, token.IPAddress)
|
||||
|
||||
exit := byAction[audit_model.UserImpersonationExit]
|
||||
require.NotNil(t, exit)
|
||||
assert.Equal(t, int64(1), exit.ActorID)
|
||||
assert.Zero(t, exit.ImpersonatorID)
|
||||
}
|
||||
|
||||
// A leaked token is only traceable if events name the token, not just its owner.
|
||||
func TestAdminAuditLogTokenCredential(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
|
||||
|
||||
req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
|
||||
Name: "audit-credential-repo",
|
||||
}).AddTokenAuth(token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
events, _, err := audit_model.FindEvents(t.Context(), &audit_model.EventSearchOptions{
|
||||
Action: audit_model.RepositoryCreate,
|
||||
ActorID: 2,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 1)
|
||||
assert.True(t, strings.HasPrefix(events[0].ActorCredential, "access-token:"), "unexpected credential %q", events[0].ActorCredential)
|
||||
assert.Equal(t, audit_model.OriginAPI, events[0].Origin)
|
||||
assert.NotEmpty(t, events[0].IPAddress)
|
||||
}
|
||||
|
||||
func TestAdminAuditLogExport(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
adminSession := loginUser(t, "user1")
|
||||
userSession := loginUser(t, "user2")
|
||||
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
|
||||
|
||||
err := audit_model.InsertEvent(t.Context(), &audit_model.Event{
|
||||
Action: audit_model.UserCreate,
|
||||
ActorID: 1,
|
||||
ActorName: "audit-export-actor",
|
||||
ScopeType: audit_model.ScopeUser,
|
||||
ScopeID: 2,
|
||||
ScopeName: "audit-export-scope",
|
||||
Origin: audit_model.OriginAPI,
|
||||
Message: "Export test event",
|
||||
Metadata: `{"source":"integration-test"}`,
|
||||
IPAddress: "192.0.2.1",
|
||||
TimestampUnix: timeutil.TimeStamp(1_700_000_000),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Button", func(t *testing.T) {
|
||||
resp := adminSession.MakeRequest(t, NewRequest(t, "GET", "/-/admin/monitor/audit_logs"), http.StatusOK)
|
||||
doc := NewHTMLParser(t, resp.Body)
|
||||
assert.Equal(t, 1, doc.doc.Find(`a[href="/-/admin/monitor/audit_logs/export"]`).Length())
|
||||
})
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
resp := adminSession.MakeRequest(t, NewRequest(t, "GET", "/-/admin/monitor/audit_logs?action=user:create&actor=user1&origin=api"), http.StatusOK)
|
||||
assert.Contains(t, resp.Body.String(), "Export test event")
|
||||
|
||||
// an actor that did not cause the event filters it out, as does an unknown one
|
||||
for _, actor := range []string{"user2", "does-not-exist"} {
|
||||
resp = adminSession.MakeRequest(t, NewRequest(t, "GET", "/-/admin/monitor/audit_logs?actor="+actor), http.StatusOK)
|
||||
assert.NotContains(t, resp.Body.String(), "Export test event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilteredExport", func(t *testing.T) {
|
||||
resp := adminSession.MakeRequest(t, NewRequest(t, "GET", "/-/admin/monitor/audit_logs/export?action=repository:create"), http.StatusOK)
|
||||
assert.NotContains(t, resp.Body.String(), "Export test event")
|
||||
})
|
||||
|
||||
t.Run("AdminOnly", func(t *testing.T) {
|
||||
userSession.MakeRequest(t, NewRequest(t, "GET", "/-/admin/monitor/audit_logs/export"), http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("JSONL", func(t *testing.T) {
|
||||
resp := adminSession.MakeRequest(t, NewRequest(t, "GET", "/-/admin/monitor/audit_logs/export"), http.StatusOK)
|
||||
|
||||
contentType, _, err := mime.ParseMediaType(resp.Header().Get("Content-Type"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "application/x-ndjson", contentType)
|
||||
|
||||
disposition, params, err := mime.ParseMediaType(resp.Header().Get("Content-Disposition"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "attachment", disposition)
|
||||
assert.True(t, strings.HasPrefix(params["filename"], "gitea-audit-log-"))
|
||||
assert.True(t, strings.HasSuffix(params["filename"], ".jsonl"))
|
||||
|
||||
found := false
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
var event audit_model.Event
|
||||
require.NoError(t, json.Unmarshal(scanner.Bytes(), &event))
|
||||
if event.Message == "Export test event" {
|
||||
found = true
|
||||
assert.Equal(t, audit_model.UserCreate, event.Action)
|
||||
assert.Equal(t, "audit-export-actor", event.Actor().Name)
|
||||
assert.Equal(t, "audit-export-scope", event.Scope().Name)
|
||||
assert.Equal(t, "integration-test", audit_model.DecodeMetadata(event.Metadata)["source"])
|
||||
assert.Equal(t, "192.0.2.1", event.IPAddress)
|
||||
assert.Equal(t, audit_model.OriginAPI, event.Origin)
|
||||
}
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
assert.True(t, found)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRevokeOAuth2GrantOfOtherUser(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// grant 1 belongs to user1, so user2 must not be able to act on it
|
||||
session := loginUser(t, "user2")
|
||||
req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/1/revoke/1", nil)
|
||||
session.MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
grant := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Grant{ID: 1})
|
||||
assert.EqualValues(t, 1, grant.UserID)
|
||||
}
|
||||
Reference in New Issue
Block a user