From da37b7916b7ef23bb334e27a4dbbb4f12aa96402 Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 12 Sep 2026 10:15:23 +0200 Subject: [PATCH] feat: Add audit logging (#38189) Co-authored-by: bircni Co-authored-by: wxiaoguang --- cmd/admin.go | 3 + cmd/admin_auth_ldap.go | 5 +- cmd/admin_user.go | 5 + cmd/audit.go | 17 ++ cmd/web.go | 9 + custom/conf/app.example.ini | 29 ++ modelmigration/migrations.go | 1 + modelmigration/v28/v353.go | 37 +++ modelmigration/v28/v353_test.go | 48 +++ models/audit/action.go | 208 +++++++++++++ models/audit/audit_event.go | 209 +++++++++++++ models/audit/audit_event_benchmark_test.go | 41 +++ models/audit/audit_event_test.go | 109 +++++++ models/audit/entity.go | 49 +++ models/audit/main_test.go | 14 + models/audit/origin.go | 14 + models/audit/scopes.go | 15 + models/auth/access_token.go | 11 + models/user/openid.go | 11 + models/user/user_system.go | 26 ++ modules/httplib/url.go | 13 + modules/setting/audit.go | 54 ++++ modules/setting/audit_test.go | 37 +++ modules/setting/setting.go | 1 + modules/web/middleware/data.go | 8 +- options/locale/locale_en-US.json | 20 ++ routers/api/v1/admin/org.go | 4 + routers/api/v1/admin/user.go | 4 + routers/api/v1/api.go | 9 + routers/api/v1/org/action.go | 13 +- routers/api/v1/org/org.go | 4 + routers/api/v1/repo/action.go | 13 +- routers/api/v1/repo/repo.go | 6 + routers/api/v1/user/action.go | 13 +- routers/api/v1/user/app.go | 30 +- routers/common/audit.go | 26 ++ routers/common/auth.go | 13 + routers/install/install.go | 4 + routers/private/hook_post_receive.go | 4 + routers/private/internal.go | 2 + routers/web/admin/applications.go | 2 +- routers/web/admin/audit.go | 71 +++++ routers/web/admin/auths.go | 4 +- routers/web/admin/emails.go | 9 + routers/web/admin/hooks.go | 8 +- routers/web/admin/users.go | 5 + routers/web/auth/2fa.go | 6 + routers/web/auth/auth.go | 5 + routers/web/auth/oauth.go | 5 +- routers/web/auth/oauth2_provider.go | 6 +- routers/web/auth/oauth_signin_sync.go | 3 +- routers/web/auth/password.go | 6 + routers/web/org/org.go | 4 + routers/web/org/projects.go | 5 + routers/web/org/setting.go | 8 +- routers/web/org/setting/audit.go | 22 ++ routers/web/org/setting_oauth2.go | 2 +- routers/web/repo/actions/view.go | 1 + routers/web/repo/projects.go | 10 +- routers/web/repo/setting/audit.go | 19 ++ routers/web/repo/setting/deploy_key.go | 3 + routers/web/repo/setting/protected_branch.go | 12 + routers/web/repo/setting/protected_tag.go | 8 + routers/web/repo/setting/secrets.go | 18 +- routers/web/repo/setting/setting.go | 16 + routers/web/repo/setting/webhook.go | 26 +- routers/web/shared/audit/audit.go | 105 +++++++ routers/web/shared/secrets/secrets.go | 33 ++- routers/web/user/setting/applications.go | 11 +- routers/web/user/setting/audit.go | 19 ++ routers/web/user/setting/keys.go | 24 +- routers/web/user/setting/oauth2.go | 17 +- routers/web/user/setting/oauth2_common.go | 73 ++++- routers/web/user/setting/security/2fa.go | 8 + routers/web/user/setting/security/openid.go | 20 +- routers/web/user/setting/security/security.go | 4 + routers/web/user/setting/security/webauthn.go | 19 +- routers/web/user/setting/webhooks.go | 8 +- routers/web/web.go | 11 +- services/actions/workflow.go | 25 +- services/asymkey/deploy_key.go | 5 + services/asymkey/ssh_key.go | 8 + services/audit/actor.go | 83 ++++++ services/audit/audit_test.go | 279 ++++++++++++++++++ services/audit/event.go | 165 +++++++++++ services/audit/export.go | 25 ++ services/audit/export_test.go | 48 +++ services/audit/main_test.go | 14 + services/audit/message.go | 66 +++++ services/audit/notifier.go | 154 ++++++++++ services/audit/notifier_test.go | 81 +++++ services/audit/refs.go | 109 +++++++ services/audit/scoped.go | 122 ++++++++ services/audit/scoped_test.go | 70 +++++ services/auth/basic.go | 8 +- services/auth/credential.go | 21 ++ services/auth/impersonate.go | 11 + services/auth/oauth2.go | 23 +- services/auth/reverseproxy.go | 4 + services/auth/source.go | 35 ++- .../auth/source/ldap/source_authenticate.go | 6 + services/auth/source/ldap/source_sync.go | 8 + .../auth/source/pam/source_authenticate.go | 4 + .../auth/source/smtp/source_authenticate.go | 4 + services/auth/source/source_group_sync.go | 4 + services/auth/sspi.go | 4 + services/context/access_log.go | 9 +- services/cron/tasks_extended.go | 24 +- services/externalaccount/user.go | 5 + services/org/org.go | 16 +- services/org/team.go | 58 +++- services/org/user.go | 11 +- services/org/user_test.go | 20 ++ services/repository/collaboration.go | 52 +++- services/repository/fork.go | 12 +- services/repository/repo_team.go | 56 +++- services/repository/repository.go | 24 +- services/repository/transfer.go | 33 ++- services/secrets/secrets.go | 16 +- services/user/email.go | 128 +++++--- services/user/update.go | 38 ++- services/user/user.go | 23 +- services/user/user_test.go | 9 + services/wiki/wiki.go | 4 + templates/admin/audit/list.tmpl | 16 + templates/admin/navbar.tmpl | 5 +- templates/org/settings/audit_logs.tmpl | 6 + templates/org/settings/navbar.tmpl | 3 + templates/repo/settings/audit_logs.tmpl | 6 + templates/repo/settings/navbar.tmpl | 3 + templates/shared/audit/list.tmpl | 89 ++++++ templates/user/settings/audit_logs.tmpl | 6 + templates/user/settings/navbar.tmpl | 3 + tests/integration/actions_trigger_test.go | 38 +-- tests/integration/admin_audit_test.go | 172 +++++++++++ .../user_settings_oauth2_grant_test.go | 27 ++ 136 files changed, 3864 insertions(+), 209 deletions(-) create mode 100644 cmd/audit.go create mode 100644 modelmigration/v28/v353.go create mode 100644 modelmigration/v28/v353_test.go create mode 100644 models/audit/action.go create mode 100644 models/audit/audit_event.go create mode 100644 models/audit/audit_event_benchmark_test.go create mode 100644 models/audit/audit_event_test.go create mode 100644 models/audit/entity.go create mode 100644 models/audit/main_test.go create mode 100644 models/audit/origin.go create mode 100644 models/audit/scopes.go create mode 100644 modules/setting/audit.go create mode 100644 modules/setting/audit_test.go create mode 100644 routers/common/audit.go create mode 100644 routers/web/admin/audit.go create mode 100644 routers/web/org/setting/audit.go create mode 100644 routers/web/repo/setting/audit.go create mode 100644 routers/web/shared/audit/audit.go create mode 100644 routers/web/user/setting/audit.go create mode 100644 services/audit/actor.go create mode 100644 services/audit/audit_test.go create mode 100644 services/audit/event.go create mode 100644 services/audit/export.go create mode 100644 services/audit/export_test.go create mode 100644 services/audit/main_test.go create mode 100644 services/audit/message.go create mode 100644 services/audit/notifier.go create mode 100644 services/audit/notifier_test.go create mode 100644 services/audit/refs.go create mode 100644 services/audit/scoped.go create mode 100644 services/audit/scoped_test.go create mode 100644 services/auth/credential.go create mode 100644 templates/admin/audit/list.tmpl create mode 100644 templates/org/settings/audit_logs.tmpl create mode 100644 templates/repo/settings/audit_logs.tmpl create mode 100644 templates/shared/audit/list.tmpl create mode 100644 templates/user/settings/audit_logs.tmpl create mode 100644 tests/integration/admin_audit_test.go create mode 100644 tests/integration/user_settings_oauth2_grant_test.go diff --git a/cmd/admin.go b/cmd/admin.go index 890ed92b179..6d9a198e2e2 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -54,6 +54,9 @@ func newAuthCommand() *cli.Command { return &cli.Command{ Name: "auth", Usage: "Modify external auth providers", + Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { + return cliAuditContext(ctx), nil + }, Commands: []*cli.Command{ microcmdAuthAddOauth(), microcmdAuthUpdateOauth(), diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go index 960535365b0..b7d50715107 100644 --- a/cmd/admin_auth_ldap.go +++ b/cmd/admin_auth_ldap.go @@ -10,6 +10,7 @@ import ( "gitea.dev/models/auth" "gitea.dev/modules/util" + auth_service "gitea.dev/services/auth" "gitea.dev/services/auth/source/ldap" "github.com/urfave/cli/v3" @@ -221,8 +222,8 @@ func microcmdAuthUpdateLdapSimpleAuth() *cli.Command { func newAuthService() *authService { return &authService{ initDB: initDB, - createAuthSource: auth.CreateSource, - updateAuthSource: auth.UpdateSource, + createAuthSource: auth_service.CreateSource, + updateAuthSource: auth_service.UpdateSource, getAuthSourceByID: auth.GetSourceByID, } } diff --git a/cmd/admin_user.go b/cmd/admin_user.go index b45aee1895d..471154b701a 100644 --- a/cmd/admin_user.go +++ b/cmd/admin_user.go @@ -4,6 +4,8 @@ package cmd import ( + "context" + "github.com/urfave/cli/v3" ) @@ -11,6 +13,9 @@ func newUserCommand() *cli.Command { return &cli.Command{ Name: "user", Usage: "Modify users", + Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) { + return cliAuditContext(ctx), nil + }, Commands: []*cli.Command{ microcmdUserCreate(), newUserListCommand(), diff --git a/cmd/audit.go b/cmd/audit.go new file mode 100644 index 00000000000..23ac1f68c77 --- /dev/null +++ b/cmd/audit.go @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + + audit_model "gitea.dev/models/audit" + user_model "gitea.dev/models/user" + "gitea.dev/services/audit" +) + +func cliAuditContext(ctx context.Context) context.Context { + ctx = audit.WithOrigin(ctx, audit_model.OriginCLI) + return audit.WithDoer(ctx, user_model.NewCLIUser()) +} diff --git a/cmd/web.go b/cmd/web.go index de886e03296..11c5d832ae1 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -15,6 +15,7 @@ import ( "strings" "time" + audit_model "gitea.dev/models/audit" "gitea.dev/modules/container" "gitea.dev/modules/graceful" "gitea.dev/modules/gtprof" @@ -26,6 +27,7 @@ import ( "gitea.dev/modules/util" "gitea.dev/routers" "gitea.dev/routers/install" + "gitea.dev/services/audit" "github.com/felixge/fgprof" "github.com/urfave/cli/v3" @@ -226,7 +228,14 @@ func serveInstalled(c *cli.Command) error { // Set up Chi routes webRoutes := routers.NormalRoutes() + + auditCtx := cliAuditContext(context.Background()) + log.Info("Audit record output: %s", setting.Audit.RecordOutput) + audit.Record(auditCtx, audit_model.SystemStartup, nil, "version", setting.AppVer) + err := listen(webRoutes, true) + + audit.Record(auditCtx, audit_model.SystemShutdown, nil) <-graceful.GetManager().Done() log.Info("PID: %d Gitea Web Finished", os.Getpid()) return err diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index c46fb6bbb65..08b12ee4891 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -545,6 +545,20 @@ INTERNAL_TOKEN = ;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility. ;ALLOWED_HOST_LIST = external +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[audit] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Where security relevant events are recorded: "disabled" or "database". +;; With "database" the events are shown in the admin, organization, repository +;; and user settings. +;RECORD_OUTPUT = disabled +;; +;; Days to keep recorded events, 0 keeps them forever. Pruning is done by the +;; "cron.delete_old_audit_events" task. +;RETENTION_DAYS = 30 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; [camo] @@ -2504,6 +2518,21 @@ LEVEL = Info ;SCHEDULE = @every 168h ;OLDER_THAN = 8760h +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Delete audit events which are older than the retention period. +;; Only registered when [audit].RECORD_OUTPUT records events and RETENTION_DAYS is set. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[cron.delete_old_audit_events] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;ENABLED = true +;RUN_AT_START = false +;NOTICE_ON_SUCCESS = false +;SCHEDULE = @every 24h +;; Defaults to [audit].RETENTION_DAYS +;OLDER_THAN = 720h + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Check for new Gitea versions diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 47e9f8a3250..6a655ebb98a 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -426,6 +426,7 @@ func prepareMigrationTasks() []*migration { newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease), newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer), newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey), + newMigration(353, "Add audit event table", v28.AddAuditEventTable), } return preparedMigrations } diff --git a/modelmigration/v28/v353.go b/modelmigration/v28/v353.go new file mode 100644 index 00000000000..331ab67ade3 --- /dev/null +++ b/modelmigration/v28/v353.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + "gitea.dev/modules/timeutil" +) + +type AuditEvent struct { + ID int64 `xorm:"pk autoincr"` + Action string `xorm:"INDEX NOT NULL"` + ActorID int64 `xorm:"INDEX NOT NULL"` + ActorName string + ActorCredential string + ImpersonatorID int64 `xorm:"INDEX"` + ImpersonatorName string + ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` + ScopeType string `xorm:"INDEX INDEX(scope) NOT NULL"` + ScopeName string + Origin string `xorm:"INDEX NOT NULL"` + Message string + Metadata string `xorm:"LONGTEXT JSON"` + IPAddress string + TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"` +} + +func (*AuditEvent) TableName() string { + return "audit_event" +} + +func AddAuditEventTable(_ context.Context, x base.EngineMigration) error { + return x.Sync(new(AuditEvent)) +} diff --git a/modelmigration/v28/v353_test.go b/modelmigration/v28/v353_test.go new file mode 100644 index 00000000000..28a39b438a6 --- /dev/null +++ b/modelmigration/v28/v353_test.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v28 + +import ( + "context" + "slices" + "testing" + + "gitea.dev/modelmigration/migrationtest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +func TestAddAuditEventTable(t *testing.T) { + x, deferable := migrationtest.PrepareTestEnv(t, 0) + defer deferable() + if x == nil || t.Failed() { + return + } + + require.NoError(t, AddAuditEventTable(t.Context(), x)) + + indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "audit_event") + require.NoError(t, err) + for _, columns := range [][]string{ + {"action"}, + {"actor_id"}, + {"scope_id", "scope_type"}, + {"scope_type"}, + {"origin"}, + {"timestamp_unix"}, + } { + assert.True(t, hasAuditIndexWithColumns(indexes, columns), "missing index on %v", columns) + } +} + +func hasAuditIndexWithColumns(indexes map[string]*schemas.Index, columns []string) bool { + for _, index := range indexes { + if slices.Equal(index.Cols, columns) { + return true + } + } + return false +} diff --git a/models/audit/action.go b/models/audit/action.go new file mode 100644 index 00000000000..19858409971 --- /dev/null +++ b/models/audit/action.go @@ -0,0 +1,208 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "slices" + "strings" +) + +type Action string + +var ( + actionMessages = map[Action]string{} + allActions []Action +) + +func define(id, message string) Action { + a := Action(id) + if _, exists := actionMessages[a]; exists { + panic("duplicate audit action: " + id) + } + actionMessages[a] = message + allActions = append(allActions, a) + return a +} + +// MessageTemplate returns the message template registered for an action. +func MessageTemplate(a Action) (string, bool) { + m, ok := actionMessages[a] + return m, ok +} + +// AllActions returns every registered action. +func AllActions() []Action { + return allActions +} + +// ActionFilters returns every exact action and its selectable hierarchy prefixes. +// A prefix is useful when an operator wants every event in a family, such as +// user:impersonation, without having to download and post-process the log. +func ActionFilters() []Action { + filters := make(map[Action]struct{}, len(allActions)*2) + for _, action := range allActions { + filters[action] = struct{}{} + parts := strings.Split(string(action), ":") + for i := 2; i < len(parts); i++ { + filters[Action(strings.Join(parts[:i], ":"))] = struct{}{} + } + } + result := make([]Action, 0, len(filters)) + for action := range filters { + result = append(result, action) + } + slices.Sort(result) + return result +} + +// IsActionFilter reports whether action is an exact action or a family prefix. +func IsActionFilter(action Action) bool { + return slices.Contains(ActionFilters(), action) +} + +var ( + UserImpersonation = define("user:impersonation:start", "User {actor} started impersonating user {scope}.") + UserImpersonationExit = define("user:impersonation:exit", "User {actor} stopped impersonating user {scope}.") + UserCreate = define("user:create", "Created user {scope}.") + UserDelete = define("user:delete", "Deleted user {scope}.") + UserAuthenticationFailTwoFactor = define("user:authentication:fail:twofactor", "Failed two-factor authentication for user {scope}.") + UserAuthenticationSource = define("user:authentication:source:update", "Changed authentication source of user {scope} to {auth_source}.") + UserActive = define("user:status:active", "Changed activation status of user {scope} to {active}.") + UserRestricted = define("user:status:restricted", "Changed restricted status of user {scope} to {restricted}.") + UserAdmin = define("user:status:admin", "Changed admin status of user {scope} to {admin}.") + UserName = define("user:name:update", "Changed user name from {previous_name} to {scope}.") + UserPassword = define("user:password", "Changed password of user {scope}.") + UserPasswordResetRequest = define("user:password:resetrequest", "Requested password reset for user {scope}.") + UserVisibility = define("user:visibility:update", "Changed visibility of user {scope} from {old_visibility} to {new_visibility}.") + UserEmailPrimaryChange = define("user:email:primary", "Changed primary email of user {scope} to {email}.") + UserEmailAdd = define("user:email:add", "Added email {email} to user {scope}.") + UserEmailActivate = define("user:email:activate", "Changed activation status of email {email} of user {scope}.") + UserEmailRemove = define("user:email:remove", "Removed email {email} from user {scope}.") + UserTwoFactorEnable = define("user:twofactor:enable", "Enabled two-factor authentication for user {scope}.") + UserTwoFactorRegenerate = define("user:twofactor:regenerate", "Regenerated two-factor authentication secret for user {scope}.") + UserTwoFactorDisable = define("user:twofactor:disable", "Disabled two-factor authentication for user {scope}.") + UserWebAuthAdd = define("user:webauth:add", "Added WebAuthn key {credential} for user {scope}.") + UserWebAuthRemove = define("user:webauth:remove", "Removed WebAuthn key {credential} from user {scope}.") + UserExternalLoginAdd = define("user:externallogin:add", "Added external login {external_id} for user {scope} using provider {provider}.") + UserExternalLoginRemove = define("user:externallogin:remove", "Removed external login from authentication source {auth_source_id} for user {scope}.") + UserOpenIDAdd = define("user:openid:add", "Associated OpenID {openid} to user {scope}.") + UserOpenIDRemove = define("user:openid:remove", "Removed OpenID {openid} from user {scope}.") + UserAccessTokenAdd = define("user:accesstoken:add", "Added access token {token} for user {scope} with scope {token_scope}.") + UserAccessTokenRemove = define("user:accesstoken:remove", "Removed access token {token} from user {scope}.") + UserOAuth2ApplicationAdd = define("user:oauth2application:add", "Added OAuth2 application {oauth2_application} for user {scope}.") + UserOAuth2ApplicationUpdate = define("user:oauth2application:update", "Updated OAuth2 application {oauth2_application} of user {scope}.") + UserOAuth2ApplicationSecret = define("user:oauth2application:secret", "Regenerated secret for OAuth2 application {oauth2_application} of user {scope}.") + UserOAuth2ApplicationGrant = define("user:oauth2application:grant", "Granted OAuth2 application {oauth2_application} access to user {scope}.") + UserOAuth2ApplicationRevoke = define("user:oauth2application:revoke", "Revoked OAuth2 grant for application {oauth2_application} of user {scope}.") + UserOAuth2ApplicationRemove = define("user:oauth2application:remove", "Removed OAuth2 application {oauth2_application} of user {scope}.") + UserKeySSHAdd = define("user:key:ssh:add", "Added SSH key {fingerprint} for user {scope}.") + UserKeySSHRemove = define("user:key:ssh:remove", "Removed SSH key {fingerprint} of user {scope}.") + UserKeyPrincipalAdd = define("user:key:principal:add", "Added principal key {key} for user {scope}.") + UserKeyPrincipalRemove = define("user:key:principal:remove", "Removed principal key {key} of user {scope}.") + UserKeyGPGAdd = define("user:key:gpg:add", "Added GPG key {gpg_key_id} for user {scope}.") + UserKeyGPGRemove = define("user:key:gpg:remove", "Removed GPG key {gpg_key_id} of user {scope}.") + UserSecretAdd = define("user:secret:add", "Added secret {secret} to user {scope}.") + UserSecretUpdate = define("user:secret:update", "Updated secret {secret} of user {scope}.") + UserSecretRemove = define("user:secret:remove", "Removed secret {secret} from user {scope}.") + UserWebhookAdd = define("user:webhook:add", "Added webhook {webhook} to user {scope}.") + UserWebhookUpdate = define("user:webhook:update", "Updated webhook {webhook} of user {scope}.") + UserWebhookRemove = define("user:webhook:remove", "Removed webhook {webhook} of user {scope}.") + + OrganizationCreate = define("organization:create", "Created organization {scope}.") + OrganizationDelete = define("organization:delete", "Deleted organization {scope}.") + OrganizationName = define("organization:name:update", "Changed organization name from {previous_name} to {scope}.") + OrganizationVisibility = define("organization:visibility", "Changed visibility of organization {scope} to {new_visibility}.") + OrganizationMemberRemove = define("organization:member:remove", "Removed user {member} from organization {scope}.") + OrganizationTeamAdd = define("organization:team:add", "Added team {team} to organization {scope}.") + OrganizationTeamUpdate = define("organization:team:update", "Updated settings of team {scope}/{team}.") + OrganizationTeamRemove = define("organization:team:remove", "Removed team {team} from organization {scope}.") + OrganizationTeamPermission = define("organization:team:permission", "Changed permission of team {scope}/{team} to {permission}.") + OrganizationTeamMemberAdd = define("organization:team:member:add", "Added user {member} to team {scope}/{team}.") + OrganizationTeamMemberRemove = define("organization:team:member:remove", "Removed user {member} from team {scope}/{team}.") + OrganizationOAuth2ApplicationAdd = define("organization:oauth2application:add", "Added OAuth2 application {oauth2_application} for organization {scope}.") + OrganizationOAuth2ApplicationUpdate = define("organization:oauth2application:update", "Updated OAuth2 application {oauth2_application} of organization {scope}.") + OrganizationOAuth2ApplicationSecret = define("organization:oauth2application:secret", "Regenerated secret for OAuth2 application {oauth2_application} of organization {scope}.") + OrganizationOAuth2ApplicationRemove = define("organization:oauth2application:remove", "Removed OAuth2 application {oauth2_application} of organization {scope}.") + OrganizationSecretAdd = define("organization:secret:add", "Added secret {secret} to organization {scope}.") + OrganizationSecretUpdate = define("organization:secret:update", "Updated secret {secret} of organization {scope}.") + OrganizationSecretRemove = define("organization:secret:remove", "Removed secret {secret} from organization {scope}.") + OrganizationWebhookAdd = define("organization:webhook:add", "Added webhook {webhook} to organization {scope}.") + OrganizationWebhookUpdate = define("organization:webhook:update", "Updated webhook {webhook} of organization {scope}.") + OrganizationWebhookRemove = define("organization:webhook:remove", "Removed webhook {webhook} of organization {scope}.") + + RepositoryCreate = define("repository:create", "Created repository {scope}.") + RepositoryCreateFork = define("repository:fork:create", "Created fork {scope} of repository {base_repo}.") + RepositoryArchive = define("repository:archive", "Archived repository {scope}.") + RepositoryUnarchive = define("repository:unarchive", "Unarchived repository {scope}.") + RepositoryDelete = define("repository:delete", "Deleted repository {scope}.") + RepositoryName = define("repository:name:update", "Changed repository name from {previous_name} to {scope}.") + RepositoryVisibility = define("repository:visibility:update", "Changed visibility of repository {scope} to {visibility}.") + RepositoryConvertFork = define("repository:fork:convert", "Converted repository {scope} from fork to regular repository.") + RepositoryConvertMirror = define("repository:mirror:convert", "Converted repository {scope} from pull mirror to regular repository.") + RepositoryMirrorPushAdd = define("repository:mirror:push:add", "Added push mirror to {remote_address} for repository {scope}.") + RepositoryMirrorPushRemove = define("repository:mirror:push:remove", "Removed push mirror to {remote_address} for repository {scope}.") + RepositorySigningVerification = define("repository:signingverification", "Changed signing verification of repository {scope} to {trust_model}.") + RepositoryTransferStart = define("repository:transfer:start", "Started repository transfer of {scope} to {new_owner}.") + RepositoryTransferFinish = define("repository:transfer:finish", "Transferred repository {scope} from {old_owner} to {new_owner}.") + RepositoryTransferCancel = define("repository:transfer:cancel", "Canceled transfer of repository {scope}.") + RepositoryWikiDelete = define("repository:wiki:delete", "Deleted wiki of repository {scope}.") + RepositoryCollaboratorAdd = define("repository:collaborator:add", "Added user {collaborator} as collaborator for repository {scope} with access mode {access_mode}.") + RepositoryCollaboratorAccess = define("repository:collaborator:access", "Changed access mode of collaborator {collaborator} of repository {scope} to {access_mode}.") + RepositoryCollaboratorRemove = define("repository:collaborator:remove", "Removed collaborator {collaborator} from repository {scope}.") + RepositoryCollaboratorTeamAdd = define("repository:collaborator:team:add", "Added team {team} as collaborator for repository {scope}.") + RepositoryCollaboratorTeamRemove = define("repository:collaborator:team:remove", "Removed team {team} as collaborator from repository {scope}.") + RepositoryBranchDefault = define("repository:branch:default", "Changed default branch of repository {scope} to {default_branch}.") + RepositoryBranchProtectionAdd = define("repository:branch:protection:add", "Added branch protection {rule} for repository {scope}.") + RepositoryBranchProtectionUpdate = define("repository:branch:protection:update", "Updated branch protection {rule} for repository {scope}.") + RepositoryBranchProtectionRemove = define("repository:branch:protection:remove", "Removed branch protection {rule} from repository {scope}.") + RepositoryTagProtectionAdd = define("repository:tag:protection:add", "Added tag protection {pattern} for repository {scope}.") + RepositoryTagProtectionUpdate = define("repository:tag:protection:update", "Updated tag protection {pattern} for repository {scope}.") + RepositoryTagProtectionRemove = define("repository:tag:protection:remove", "Removed tag protection {pattern} from repository {scope}.") + RepositoryWebhookAdd = define("repository:webhook:add", "Added webhook {webhook} to repository {scope}.") + RepositoryWebhookUpdate = define("repository:webhook:update", "Updated webhook {webhook} of repository {scope}.") + RepositoryWebhookRemove = define("repository:webhook:remove", "Removed webhook {webhook} of repository {scope}.") + RepositoryDeployKeyAdd = define("repository:deploykey:add", "Added deploy key {deploy_key} for repository {scope}.") + RepositoryDeployKeyRemove = define("repository:deploykey:remove", "Removed deploy key {deploy_key} from repository {scope}.") + RepositorySecretAdd = define("repository:secret:add", "Added secret {secret} to repository {scope}.") + RepositorySecretUpdate = define("repository:secret:update", "Updated secret {secret} of repository {scope}.") + RepositorySecretRemove = define("repository:secret:remove", "Removed secret {secret} from repository {scope}.") + + IssueCreate = define("issue:create", "Created issue {issue} in repository {scope}.") + IssueDelete = define("issue:delete", "Deleted issue {issue} from repository {scope}.") + IssueCommentCreate = define("issue:comment:create", "Added comment {comment_id} to issue {issue} in repository {scope}.") + IssueCommentDelete = define("issue:comment:delete", "Deleted comment {comment_id} from issue {issue} in repository {scope}.") + + PullRequestCreate = define("pr:create", "Created pull request {pull_request} in repository {scope}.") + PullRequestDelete = define("pr:delete", "Deleted pull request {pull_request} from repository {scope}.") + PullRequestMerge = define("pr:merge", "Merged pull request {pull_request} in repository {scope}.") + PullRequestCommentCreate = define("pr:comment:create", "Added comment {comment_id} to pull request {pull_request} in repository {scope}.") + PullRequestCommentDelete = define("pr:comment:delete", "Deleted comment {comment_id} from pull request {pull_request} in repository {scope}.") + + ProjectCreate = define("project:create", "Created project {project} in {scope}.") + ProjectUpdate = define("project:update", "Updated project {project} in {scope}.") + ProjectDelete = define("project:delete", "Deleted project {project} from {scope}.") + + WikiPageCreate = define("wiki:page:create", "Created wiki page {page} in repository {scope}.") + WikiPageUpdate = define("wiki:page:update", "Updated wiki page {page} in repository {scope}.") + WikiPageDelete = define("wiki:page:delete", "Deleted wiki page {page} from repository {scope}.") + + ActionsWorkflowEnable = define("actions:workflow:enable", "Enabled Actions workflow {workflow} in repository {scope}.") + ActionsWorkflowDisable = define("actions:workflow:disable", "Disabled Actions workflow {workflow} in repository {scope}.") + ActionsWorkflowDispatch = define("actions:workflow:dispatch", "Dispatched Actions workflow {workflow} on {ref} in repository {scope}.") + + // Do not change the startup message anymore. We guarantee the stability of this message for + // users wanting to parse the log themselves to be able to trace back events across gitea versions. + SystemStartup = define("system:startup", "System started [Gitea {version}]") + SystemShutdown = define("system:shutdown", "System shutdown") + SystemWebhookAdd = define("system:webhook:add", "Added instance-wide webhook {webhook}.") + SystemWebhookUpdate = define("system:webhook:update", "Updated instance-wide webhook {webhook}.") + SystemWebhookRemove = define("system:webhook:remove", "Removed instance-wide webhook {webhook}.") + SystemAuthenticationSourceAdd = define("system:authenticationsource:add", "Created authentication source {auth_source}.") + SystemAuthenticationSourceUpdate = define("system:authenticationsource:update", "Updated authentication source {auth_source}.") + SystemAuthenticationSourceRemove = define("system:authenticationsource:remove", "Removed authentication source {auth_source}.") + SystemOAuth2ApplicationAdd = define("system:oauth2application:add", "Added instance-wide OAuth2 application {oauth2_application}.") + SystemOAuth2ApplicationUpdate = define("system:oauth2application:update", "Updated instance-wide OAuth2 application {oauth2_application}.") + SystemOAuth2ApplicationSecret = define("system:oauth2application:secret", "Regenerated secret for instance-wide OAuth2 application {oauth2_application}.") + SystemOAuth2ApplicationRemove = define("system:oauth2application:remove", "Removed instance-wide OAuth2 application {oauth2_application}.") +) diff --git a/models/audit/audit_event.go b/models/audit/audit_event.go new file mode 100644 index 00000000000..557b4e37548 --- /dev/null +++ b/models/audit/audit_event.go @@ -0,0 +1,209 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + "time" + + "gitea.dev/models/db" + "gitea.dev/modules/json" + "gitea.dev/modules/log" + "gitea.dev/modules/timeutil" + + "xorm.io/builder" +) + +func init() { + db.RegisterModel(new(Event)) +} + +type Event struct { + ID int64 `xorm:"pk autoincr"` + Action Action `xorm:"INDEX NOT NULL"` + ActorID int64 `xorm:"INDEX NOT NULL"` + ActorName string + ActorCredential string // Credential the actor acted with, e.g. "access-token:", "oauth2-grant:", "gitea-actions:" or "deploy-key:". + ImpersonatorID int64 `xorm:"INDEX"` // Admin acting as the actor; zero when the actor acted themselves. + ImpersonatorName string + ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` // Entity ID within ScopeType; zero for system. + ScopeType ScopeType `xorm:"INDEX INDEX(scope) NOT NULL"` + ScopeName string + Origin Origin `xorm:"INDEX NOT NULL"` + Message string + Metadata string `xorm:"LONGTEXT JSON"` + IPAddress string + TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"` +} + +func (*Event) TableName() string { + return "audit_event" +} + +func (e *Event) Actor() EntityRef { + return EntityRef{Type: ScopeUser, ID: e.ActorID, Name: e.ActorName} +} + +// Impersonator returns the admin who acted as the actor, or nil. +func (e *Event) Impersonator() *EntityRef { + if e.ImpersonatorID == 0 && e.ImpersonatorName == "" { + return nil + } + return &EntityRef{Type: ScopeUser, ID: e.ImpersonatorID, Name: e.ImpersonatorName} +} + +func (e *Event) Scope() EntityRef { + return EntityRef{Type: e.ScopeType, ID: e.ScopeID, Name: e.ScopeName} +} + +func (e *Event) Time() time.Time { + return e.TimestampUnix.AsTime() +} + +// eventJSON is the nested JSONL export shape. +type eventJSON struct { + Action Action `json:"action"` + Actor EntityRef `json:"actor"` + ActorCredential string `json:"actor_credential,omitempty"` + Impersonator *EntityRef `json:"impersonator,omitempty"` + Scope EntityRef `json:"scope"` + Message string `json:"message"` + Metadata map[string]any `json:"metadata,omitempty"` + Time time.Time `json:"time"` + IPAddress string `json:"ip_address"` + Origin Origin `json:"origin"` +} + +func (e *Event) MarshalJSON() ([]byte, error) { + return json.Marshal(eventJSON{ + Action: e.Action, + Actor: e.Actor(), + ActorCredential: e.ActorCredential, + Impersonator: e.Impersonator(), + Scope: e.Scope(), + Message: e.Message, + Metadata: DecodeMetadata(e.Metadata), + Time: e.Time(), + IPAddress: e.IPAddress, + Origin: e.Origin, + }) +} + +func (e *Event) UnmarshalJSON(data []byte) error { + var j eventJSON + if err := json.Unmarshal(data, &j); err != nil { + return err + } + e.Action = j.Action + e.ActorID = j.Actor.ID + e.ActorName = j.Actor.Name + e.ActorCredential = j.ActorCredential + if j.Impersonator != nil { + e.ImpersonatorID = j.Impersonator.ID + e.ImpersonatorName = j.Impersonator.Name + } + e.ScopeType = j.Scope.Type + e.ScopeID = j.Scope.ID + e.ScopeName = j.Scope.Name + e.Message = j.Message + e.Metadata = EncodeMetadata(j.Metadata) + e.IPAddress = j.IPAddress + e.Origin = j.Origin + e.TimestampUnix = timeutil.TimeStamp(j.Time.Unix()) + return nil +} + +func EncodeMetadata(m map[string]any) string { + if len(m) == 0 { + return "" + } + b, err := json.Marshal(m) + if err != nil { + log.Error("Failed to encode audit metadata: %v", err) + return "" + } + return string(b) +} + +func DecodeMetadata(raw string) map[string]any { + if raw == "" { + return nil + } + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + log.Error("Failed to decode audit metadata: %v", err) + return nil + } + return m +} + +func InsertEvent(ctx context.Context, e *Event) error { + return db.Insert(ctx, e) +} + +// DeleteOldEvents removes events older than the given duration, keeping everything if it is not positive. +func DeleteOldEvents(ctx context.Context, olderThan time.Duration) error { + if olderThan <= 0 { + return nil + } + _, err := db.GetEngine(ctx).Where("timestamp_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Event{}) + return err +} + +type EventSort string + +const ( + SortTimestampAsc EventSort = "timestamp_asc" + SortTimestampDesc EventSort = "timestamp_desc" +) + +type EventSearchOptions struct { + db.ListOptions + Action Action + // ActionPrefix filters an action family. It is mutually exclusive with Action. + ActionPrefix Action + ActorID int64 + ScopeType ScopeType + ScopeID int64 + Origin Origin + Sort EventSort +} + +func (opts *EventSearchOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.Action != "" { + cond = cond.And(builder.Eq{"action": opts.Action}) + } else if opts.ActionPrefix != "" { + cond = cond.And(builder.Like{"action", string(opts.ActionPrefix) + ":%"}) + } + if opts.ActorID != 0 { + // an impersonated event belongs to both the actor and the admin behind it + cond = cond.And(builder.Eq{"actor_id": opts.ActorID}.Or(builder.Eq{"impersonator_id": opts.ActorID})) + } + // applied independently so a missing scope ID narrows the query instead of + // silently widening it to every scope + if opts.ScopeType != "" { + cond = cond.And(builder.Eq{"scope_type": opts.ScopeType}) + } + if opts.ScopeID != 0 { + cond = cond.And(builder.Eq{"scope_id": opts.ScopeID}) + } + if opts.Origin != "" { + cond = cond.And(builder.Eq{"origin": opts.Origin}) + } + + return cond +} + +func (opts *EventSearchOptions) ToOrders() string { + if opts.Sort == SortTimestampAsc { + return "timestamp_unix ASC, id ASC" + } + return "timestamp_unix DESC, id DESC" +} + +func FindEvents(ctx context.Context, opts *EventSearchOptions) ([]*Event, int64, error) { + return db.FindAndCount[Event](ctx, opts) +} diff --git a/models/audit/audit_event_benchmark_test.go b/models/audit/audit_event_benchmark_test.go new file mode 100644 index 00000000000..ac542e68d45 --- /dev/null +++ b/models/audit/audit_event_benchmark_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + "testing" + + "gitea.dev/models/unittest" + "gitea.dev/modules/timeutil" +) + +// BenchmarkInsertEvent measures the synchronous database cost added when audit +// recording is enabled. Keep it separate from router benchmarks so it remains +// comparable across changes to request handling. +func BenchmarkInsertEvent(b *testing.B) { + if err := unittest.PrepareTestDatabase(); err != nil { + b.Fatal(err) + } + + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + event := &Event{ + Action: UserPassword, + ActorID: 1, + ActorName: "actor", + ScopeType: ScopeUser, + ScopeID: 2, + ScopeName: "scope", + Origin: OriginUI, + Metadata: `{"source":"benchmark"}`, + TimestampUnix: timeutil.TimeStamp(i + 1), + } + if err := InsertEvent(ctx, event); err != nil { + b.Fatal(err) + } + } +} diff --git a/models/audit/audit_event_test.go b/models/audit/audit_event_test.go new file mode 100644 index 00000000000..46bc470363e --- /dev/null +++ b/models/audit/audit_event_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "testing" + "time" + + "gitea.dev/models/unittest" + "gitea.dev/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindEventsScopeFilters(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + events := []*Event{ + {Action: UserCreate, ScopeType: ScopeUser, ScopeID: 5, Origin: OriginUI, TimestampUnix: timeutil.TimeStamp(1)}, + {Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 5, Origin: OriginAPI, TimestampUnix: timeutil.TimeStamp(1)}, + {Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 6, Origin: OriginCLI, TimestampUnix: timeutil.TimeStamp(1)}, + {Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 7, Origin: OriginSystem, TimestampUnix: timeutil.TimeStamp(1)}, + } + for _, event := range events { + require.NoError(t, InsertEvent(t.Context(), event)) + } + + byType, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeType: ScopeRepository}) + require.NoError(t, err) + assert.Len(t, byType, 3) + + byID, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeID: 5}) + require.NoError(t, err) + assert.Len(t, byID, 2) + + byScope, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeType: ScopeRepository, ScopeID: 5}) + require.NoError(t, err) + assert.Len(t, byScope, 1) + + byOrigin, _, err := FindEvents(t.Context(), &EventSearchOptions{Origin: OriginAPI}) + require.NoError(t, err) + assert.Len(t, byOrigin, 1) + + bySystemOrigin, _, err := FindEvents(t.Context(), &EventSearchOptions{Origin: OriginSystem}) + require.NoError(t, err) + assert.Len(t, bySystemOrigin, 1) +} + +func TestFindEventsActionPrefixFilter(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + for _, action := range []Action{UserImpersonation, UserImpersonationExit, UserCreate} { + require.NoError(t, InsertEvent(t.Context(), &Event{Action: action, ScopeType: ScopeUser, ScopeID: 1, TimestampUnix: timeutil.TimeStamp(1)})) + } + + events, _, err := FindEvents(t.Context(), &EventSearchOptions{ActionPrefix: "user:impersonation"}) + require.NoError(t, err) + assert.Len(t, events, 2) + + exact, _, err := FindEvents(t.Context(), &EventSearchOptions{Action: UserImpersonationExit}) + require.NoError(t, err) + assert.Len(t, exact, 1) + assert.Equal(t, UserImpersonationExit, exact[0].Action) +} + +// Filtering for an admin must surface what they did while impersonating someone. +func TestFindEventsActorFilterIncludesImpersonations(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + events := []*Event{ + {Action: UserPassword, ActorID: 10, ScopeType: ScopeUser, ScopeID: 10, TimestampUnix: timeutil.TimeStamp(1)}, + {Action: UserPassword, ActorID: 11, ImpersonatorID: 10, ScopeType: ScopeUser, ScopeID: 11, TimestampUnix: timeutil.TimeStamp(2)}, + {Action: UserPassword, ActorID: 12, ScopeType: ScopeUser, ScopeID: 12, TimestampUnix: timeutil.TimeStamp(3)}, + } + for _, event := range events { + require.NoError(t, InsertEvent(t.Context(), event)) + } + + byAdmin, _, err := FindEvents(t.Context(), &EventSearchOptions{ActorID: 10}) + require.NoError(t, err) + assert.Len(t, byAdmin, 2) + + byImpersonated, _, err := FindEvents(t.Context(), &EventSearchOptions{ActorID: 11}) + require.NoError(t, err) + assert.Len(t, byImpersonated, 1) +} + +func TestDeleteOldEvents(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + now := time.Now() + old := &Event{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 1, TimestampUnix: timeutil.TimeStamp(now.Add(-48 * time.Hour).Unix())} + recent := &Event{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 2, TimestampUnix: timeutil.TimeStamp(now.Unix())} + require.NoError(t, InsertEvent(t.Context(), old)) + require.NoError(t, InsertEvent(t.Context(), recent)) + + require.NoError(t, DeleteOldEvents(t.Context(), 0)) // keeps everything + _, count, err := FindEvents(t.Context(), &EventSearchOptions{}) + require.NoError(t, err) + assert.EqualValues(t, 2, count) + + require.NoError(t, DeleteOldEvents(t.Context(), 24*time.Hour)) + remaining, _, err := FindEvents(t.Context(), &EventSearchOptions{}) + require.NoError(t, err) + require.Len(t, remaining, 1) + assert.Equal(t, recent.ID, remaining[0].ID) +} diff --git a/models/audit/entity.go b/models/audit/entity.go new file mode 100644 index 00000000000..d8c10d77c87 --- /dev/null +++ b/models/audit/entity.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "net/url" + + "gitea.dev/modules/setting" + "gitea.dev/modules/util" +) + +// EntityRef is a denormalized reference persisted at record time. +type EntityRef struct { + Type ScopeType `json:"type"` + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +func (r EntityRef) DisplayName() string { + if r.Name != "" { + return r.Name + } + if r.Type == ScopeSystem { + return "System" + } + return "" +} + +func (r EntityRef) HomeLink() string { + switch r.Type { + case ScopeUser, ScopeOrganization: + if r.Name == "" { + return "" + } + return setting.AppSubURL + "/" + url.PathEscape(r.Name) + case ScopeRepository: + if r.Name == "" { + return "" + } + return setting.AppSubURL + "/" + util.PathEscapeSegments(r.Name) + default: + return "" + } +} + +func (r EntityRef) HasLink() bool { + return r.HomeLink() != "" && r.ID > 0 +} diff --git a/models/audit/main_test.go b/models/audit/main_test.go new file mode 100644 index 00000000000..e628abeb51d --- /dev/null +++ b/models/audit/main_test.go @@ -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) +} diff --git a/models/audit/origin.go b/models/audit/origin.go new file mode 100644 index 00000000000..91c61db6645 --- /dev/null +++ b/models/audit/origin.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +// Origin identifies how an audit event was initiated. +type Origin string + +const ( + OriginUI Origin = "ui" + OriginAPI Origin = "api" + OriginCLI Origin = "cli" + OriginSystem Origin = "system" +) diff --git a/models/audit/scopes.go b/models/audit/scopes.go new file mode 100644 index 00000000000..3cdfb51f239 --- /dev/null +++ b/models/audit/scopes.go @@ -0,0 +1,15 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +// ScopeType identifies the unit an audit event belongs to (for filtering in UI). +// Target-specific details live in Metadata, not as typed objects in the audit package. +type ScopeType string + +const ( + ScopeSystem ScopeType = "system" + ScopeUser ScopeType = "user" + ScopeOrganization ScopeType = "organization" + ScopeRepository ScopeType = "repository" +) diff --git a/models/auth/access_token.go b/models/auth/access_token.go index 5d324174a0d..c97bcdb3e5a 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -158,6 +158,17 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error { return err } +// GetAccessTokenByID returns the access token with the given ID owned by userID. +func GetAccessTokenByID(ctx context.Context, id, userID int64) (*AccessToken, error) { + t, has, err := db.Get[AccessToken](ctx, builder.Eq{"id": id, "uid": userID}) + if err != nil { + return nil, err + } else if !has { + return nil, util.NewNotExistErrorf("access token not found") + } + return t, nil +} + // DeleteAccessTokenByID deletes access token by given ID. func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error { cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID}) diff --git a/models/user/openid.go b/models/user/openid.go index dd4682b4403..98d0128458f 100644 --- a/models/user/openid.go +++ b/models/user/openid.go @@ -80,6 +80,17 @@ func AddUserOpenID(ctx context.Context, openid *UserOpenID) error { return db.Insert(ctx, openid) } +// GetUserOpenIDByID returns the OpenID with the given ID owned by uid. +func GetUserOpenIDByID(ctx context.Context, id, uid int64) (*UserOpenID, error) { + oid, has, err := db.Get[UserOpenID](ctx, builder.Eq{"id": id, "uid": uid}) + if err != nil { + return nil, err + } else if !has { + return nil, util.NewNotExistErrorf("OpenID is unknown") + } + return oid, nil +} + // DeleteUserOpenID deletes an openid address of given user. func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) { var deleted int64 diff --git a/models/user/user_system.go b/models/user/user_system.go index aa04015468e..14304eb1174 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -90,6 +90,32 @@ func NewDeployKeyUserWithKeyID(id int64) *User { return u } +const ( + CLIUserID int64 = -4 + CLIUserName = "CLI" +) + +func NewCLIUser() *User { + return &User{ + ID: CLIUserID, + Name: CLIUserName, + LowerName: strings.ToLower(CLIUserName), + } +} + +const ( + AuthenticationSourceUserID int64 = -5 + AuthenticationSourceUserName = "AuthenticationSource" +) + +func NewAuthenticationSourceUser() *User { + return &User{ + ID: AuthenticationSourceUserID, + Name: AuthenticationSourceUserName, + LowerName: strings.ToLower(AuthenticationSourceUserName), + } +} + func GetSystemUserByName(name string) *User { lowerName := strings.ToLower(name) uid := globalVars().systemUserNameIdMap[lowerName] diff --git a/modules/httplib/url.go b/modules/httplib/url.go index f83214966dd..cbd6a44e347 100644 --- a/modules/httplib/url.go +++ b/modules/httplib/url.go @@ -40,6 +40,19 @@ func MarkRequestSupportPublicURL(ctx reqctx.RequestContext) { ctx.SetContextValue(contextKeySupportPublicURL, true) } +// RemoteHost returns the host part of req.RemoteAddr, or the full address when +// it is not host:port form. +func RemoteHost(req *http.Request) string { + if req == nil { + return "" + } + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return req.RemoteAddr + } + return host +} + func urlIsRelative(s string, u *url.URL) bool { // Unfortunately, browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH" // Therefore we should ignore these redirect locations to prevent open redirects diff --git a/modules/setting/audit.go b/modules/setting/audit.go new file mode 100644 index 00000000000..58243d6f116 --- /dev/null +++ b/modules/setting/audit.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "strings" + "time" + + "gitea.dev/modules/log" +) + +type AuditRecordOutput string + +const ( + AuditRecordOutputDisabled AuditRecordOutput = "disabled" + AuditRecordOutputDatabase AuditRecordOutput = "database" +) + +var Audit = struct { + RecordOutput AuditRecordOutput `ini:"RECORD_OUTPUT"` + RetentionDays int64 `ini:"RETENTION_DAYS"` +}{ + RecordOutput: AuditRecordOutputDisabled, + RetentionDays: 30, +} + +func loadAuditFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "audit", &Audit) + + Audit.RecordOutput = AuditRecordOutput(strings.ToLower(strings.TrimSpace(string(Audit.RecordOutput)))) + switch Audit.RecordOutput { + case "": + Audit.RecordOutput = AuditRecordOutputDisabled + case AuditRecordOutputDisabled, AuditRecordOutputDatabase: + default: + log.Error("Invalid [audit].RECORD_OUTPUT %q, audit records are disabled", Audit.RecordOutput) + Audit.RecordOutput = AuditRecordOutputDisabled + } + + if Audit.RetentionDays < 0 { + Audit.RetentionDays = 0 // keep forever + } +} + +// AuditRetentionPeriod is the age at which recorded events are pruned, zero meaning they are kept forever. +func AuditRetentionPeriod() time.Duration { + return time.Duration(Audit.RetentionDays) * 24 * time.Hour +} + +// AuditRecordEnabled reports whether audit events are recorded at all. +func AuditRecordEnabled() bool { + return Audit.RecordOutput != AuditRecordOutputDisabled +} diff --git a/modules/setting/audit_test.go b/modules/setting/audit_test.go new file mode 100644 index 00000000000..7cd21de8cbc --- /dev/null +++ b/modules/setting/audit_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadAuditFrom(t *testing.T) { + for _, tc := range []struct { + name string + cfg string + expected AuditRecordOutput + }{ + {name: "DisabledByDefault", cfg: "", expected: AuditRecordOutputDisabled}, + {name: "Database", cfg: "[audit]\nRECORD_OUTPUT = Database\n", expected: AuditRecordOutputDatabase}, + {name: "Empty", cfg: "[audit]\nRECORD_OUTPUT =\n", expected: AuditRecordOutputDisabled}, + {name: "Invalid", cfg: "[audit]\nRECORD_OUTPUT = nonsense\n", expected: AuditRecordOutputDisabled}, + } { + t.Run(tc.name, func(t *testing.T) { + defer test.MockVariableValue(&Audit)() + + cfg, err := NewConfigProviderFromData(tc.cfg) + require.NoError(t, err) + loadAuditFrom(cfg) + + assert.Equal(t, tc.expected, Audit.RecordOutput) + assert.Equal(t, tc.expected != AuditRecordOutputDisabled, AuditRecordEnabled()) + }) + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index ccf43eaaed0..696d945bb8b 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -120,6 +120,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { // WARNING: don't change the sequence except you know what you are doing. loadRunModeFrom(cfg) loadLogGlobalFrom(cfg) + loadAuditFrom(cfg) loadServerFrom(cfg) loadSSHFrom(cfg) diff --git a/modules/web/middleware/data.go b/modules/web/middleware/data.go index b42e0753ee6..dfe8e10a471 100644 --- a/modules/web/middleware/data.go +++ b/modules/web/middleware/data.go @@ -12,7 +12,13 @@ import ( "gitea.dev/modules/setting" ) -const ContextDataKeySignedUser = "SignedUser" +const ( + ContextDataKeySignedUser = "SignedUser" + // ContextDataKeyImpersonator holds the admin acting as the signed-in user, if any. + ContextDataKeyImpersonator = "Impersonator" + // ContextDataKeyAuthCredential names the credential the request authenticated with, e.g. "access-token:42". + ContextDataKeyAuthCredential = "AuthCredential" +) func GetContextData(c context.Context) reqctx.ContextData { if rc := reqctx.GetRequestDataStore(c); rc != nil { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index b187b1d5dc1..0c53db9629b 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3051,6 +3051,8 @@ "admin.dashboard.gc_times": "GC Times", "admin.dashboard.delete_old_actions": "Delete all old activities from database", "admin.dashboard.delete_old_actions.started": "Deletion of all old activities from database started", + "admin.dashboard.delete_old_audit_events": "Delete audit events older than the retention period", + "admin.dashboard.delete_old_audit_events.started": "Deletion of old audit events started", "admin.dashboard.update_checker": "Update checker", "admin.dashboard.delete_old_system_notices": "Delete all old system notices from database", "admin.dashboard.gc_lfs": "Garbage-collect LFS meta objects", @@ -3764,6 +3766,24 @@ "secrets.deletion.success": "The secret has been removed.", "secrets.deletion.failed": "Failed to remove secret.", "secrets.management": "Secrets Management", + + "audit.title": "Audit Log", + "audit.export": "Export JSONL", + "audit.actor": "Actor", + "audit.scope": "Scope", + "audit.action": "Action", + "audit.origin": "Origin", + "audit.details": "Details", + "audit.ip_address": "IP Address", + "audit.timestamp": "Timestamp", + "audit.no_events": "There are no audit events matching the filter.", + "audit.disabled": "Audit event recording is disabled. Set [audit] RECORD_OUTPUT = database in app.ini to record new events.", + "audit.impersonating_as": "as", + "audit.filter.actor": "Filter by actor…", + "audit.filter.action": "All actions", + "audit.filter.origin": "All origins", + "audit.deleted.actor": "(removed)", + "audit.deleted.type": "(removed %[1]s with ID %[2]v)", "actions.actions": "Actions", "actions.unit.desc": "Manage actions", "actions.status.unknown": "Unknown", diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go index cab34f5f1ee..236fb8a30d3 100644 --- a/routers/api/v1/admin/org.go +++ b/routers/api/v1/admin/org.go @@ -7,12 +7,14 @@ package admin import ( "net/http" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" "gitea.dev/models/organization" user_model "gitea.dev/models/user" api "gitea.dev/modules/structs" "gitea.dev/modules/web" "gitea.dev/routers/api/v1/utils" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" ) @@ -74,6 +76,8 @@ func CreateOrg(ctx *context.APIContext) { return } + audit.Record(ctx, audit_model.OrganizationCreate, org.AsUser()) + ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org)) } diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index d7cd45fd3c4..ce7bb0896f8 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -10,6 +10,7 @@ import ( "net/http" asymkey_model "gitea.dev/models/asymkey" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" "gitea.dev/models/db" org_model "gitea.dev/models/organization" @@ -26,6 +27,7 @@ import ( "gitea.dev/routers/api/v1/user" "gitea.dev/routers/api/v1/utils" asymkey_service "gitea.dev/services/asymkey" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" "gitea.dev/services/mailer" @@ -152,6 +154,8 @@ func CreateUser(ctx *context.APIContext) { ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", u.Email)) } + audit.Record(ctx, audit_model.UserCreate, u) + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) // Send email notification. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f27d58a6f72..8953647a99a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -68,6 +68,7 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" auth_model "gitea.dev/models/auth" "gitea.dev/models/organization" "gitea.dev/models/perm" @@ -95,6 +96,7 @@ import ( "gitea.dev/routers/api/v1/user" "gitea.dev/routers/common" "gitea.dev/services/actions" + "gitea.dev/services/audit" "gitea.dev/services/auth" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -124,7 +126,13 @@ func sudo() func(ctx *context.APIContext) { return } log.Trace("Sudo from (%s) to: %s", ctx.Doer.Name, user.Name) + + audit.Record(ctx, audit_model.UserImpersonation, user) + ctx.Doer = user + // keep the audit actor in step with the effective doer, and keep the admin attached to it + ctx.Data[middleware.ContextDataKeyImpersonator] = ctx.Data[middleware.ContextDataKeySignedUser] + ctx.Data[middleware.ContextDataKeySignedUser] = user } else { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only administrators allowed to sudo.", @@ -1021,6 +1029,7 @@ func Routes() *web.Router { } m.AfterRouting(context.APIContexter()) + m.AfterRouting(common.AuditOrigin(audit_model.OriginAPI)) m.AfterRouting(checkDeprecatedAuthMethods) // Get user from session if logged in. diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 55ec043c048..92eeb6bdbb4 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -16,6 +16,7 @@ import ( "gitea.dev/routers/api/v1/shared" "gitea.dev/routers/api/v1/utils" actions_service "gitea.dev/services/actions" + "gitea.dev/services/audit" "gitea.dev/services/context" secret_service "gitea.dev/services/secrets" ) @@ -108,12 +109,18 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) { opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx) - _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description) + s, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description) if err != nil { ctx.APIErrorAuto(err) return } + actions := audit.SecretUpdate + if created { + actions = audit.SecretAdd + } + audit.RecordScoped(ctx, ctx.Org.Organization.AsUser(), nil, actions, "secret", s.Name) + if created { ctx.Status(http.StatusCreated) } else { @@ -149,12 +156,14 @@ func (Action) DeleteSecret(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname")) + s, err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname")) if err != nil { ctx.APIErrorAuto(err) return } + audit.RecordScoped(ctx, ctx.Org.Organization.AsUser(), nil, audit.SecretRemove, "secret", s.Name) + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 99a3b64c2de..ed450d21e92 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -11,6 +11,7 @@ import ( "net/http" activities_model "gitea.dev/models/activities" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" "gitea.dev/models/organization" "gitea.dev/models/perm" @@ -25,6 +26,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/routers/api/v1/user" "gitea.dev/routers/api/v1/utils" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" feed_service "gitea.dev/services/feed" @@ -296,6 +298,8 @@ func Create(ctx *context.APIContext) { return } + audit.Record(ctx, audit_model.OrganizationCreate, org.AsUser()) + ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org)) } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index a9813d11ff1..f2cf6834e09 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -30,6 +30,7 @@ import ( "gitea.dev/routers/api/v1/shared" "gitea.dev/routers/api/v1/utils" actions_service "gitea.dev/services/actions" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" secret_service "gitea.dev/services/secrets" @@ -137,12 +138,18 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) { opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx) - _, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.PathParam("secretname"), opt.Data, opt.Description) + s, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.PathParam("secretname"), opt.Data, opt.Description) if err != nil { ctx.APIErrorAuto(err) return } + actions := audit.SecretUpdate + if created { + actions = audit.SecretAdd + } + audit.RecordScoped(ctx, nil, repo, actions, "secret", s.Name) + if created { ctx.Status(http.StatusCreated) } else { @@ -185,12 +192,14 @@ func (Action) DeleteSecret(ctx *context.APIContext) { repo := ctx.Repo.Repository - err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.PathParam("secretname")) + s, err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.PathParam("secretname")) if err != nil { ctx.APIErrorAuto(err) return } + audit.RecordScoped(ctx, nil, repo, audit.SecretRemove, "secret", s.Name) + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index e264a9bf52a..53bb3c5ea44 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -14,6 +14,7 @@ import ( "time" activities_model "gitea.dev/models/activities" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" "gitea.dev/models/organization" "gitea.dev/models/perm" @@ -33,6 +34,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/routers/api/v1/utils" actions_service "gitea.dev/services/actions" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" feed_service "gitea.dev/services/feed" @@ -729,6 +731,10 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err return err } + if visibilityChanged { + audit.Record(ctx, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate) + } + if updateRepoLicense { if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{ RepoID: ctx.Repo.Repository.ID, diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 0974c7a2306..13507bf614d 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -15,6 +15,7 @@ import ( "gitea.dev/routers/api/v1/shared" "gitea.dev/routers/api/v1/utils" actions_service "gitea.dev/services/actions" + "gitea.dev/services/audit" "gitea.dev/services/context" secret_service "gitea.dev/services/secrets" ) @@ -50,12 +51,18 @@ func CreateOrUpdateSecret(ctx *context.APIContext) { opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx) - _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description) + s, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description) if err != nil { ctx.APIErrorAuto(err) return } + actions := audit.SecretUpdate + if created { + actions = audit.SecretAdd + } + audit.RecordScoped(ctx, ctx.Doer, nil, actions, "secret", s.Name) + if created { ctx.Status(http.StatusCreated) } else { @@ -86,12 +93,14 @@ func DeleteSecret(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname")) + s, err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname")) if err != nil { ctx.APIErrorAuto(err) return } + audit.RecordScoped(ctx, ctx.Doer, nil, audit.SecretRemove, "secret", s.Name) + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index 5c1ab20464a..a5df04a2cbc 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -10,11 +10,13 @@ import ( "strconv" "strings" + audit_model "gitea.dev/models/audit" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" api "gitea.dev/modules/structs" "gitea.dev/modules/web" "gitea.dev/routers/api/v1/utils" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" "gitea.dev/services/forms" @@ -149,6 +151,9 @@ func CreateAccessToken(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + + audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.ContextUser, "token", t.Name, "token_scope", t.Scope) + ctx.JSON(http.StatusCreated, &api.AccessToken{ Name: t.Name, Token: t.Token, @@ -211,11 +216,19 @@ func DeleteAccessToken(ctx *context.APIContext) { } } - if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil { + t, err := auth_model.GetAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID) + if err != nil { ctx.APIErrorAuto(err) return } + if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.ContextUser.ID); err != nil { + ctx.APIErrorAuto(err) + return + } + + audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.ContextUser, "token", t.Name) + ctx.Status(http.StatusNoContent) } @@ -261,6 +274,8 @@ func CreateOauth2Application(ctx *context.APIContext) { } app.ClientSecret = secret + audit.Record(ctx, audit_model.UserOAuth2ApplicationAdd, ctx.Doer, "oauth2_application", app.Name) + ctx.JSON(http.StatusCreated, convert.ToOAuth2Application(app)) } @@ -323,6 +338,15 @@ func DeleteOauth2Application(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" appID := ctx.PathParamInt64("id") + app, err := auth_model.GetOAuth2ApplicationByID(ctx, appID) + if err != nil { + if auth_model.IsErrOAuthApplicationNotFound(err) { + ctx.APIErrorNotFound() + } else { + ctx.APIErrorInternal(err) + } + return + } if err := auth_model.DeleteOAuth2Application(ctx, appID, ctx.Doer.ID); err != nil { if auth_model.IsErrOAuthApplicationNotFound(err) { ctx.APIErrorNotFound() @@ -332,6 +356,8 @@ func DeleteOauth2Application(ctx *context.APIContext) { return } + audit.Record(ctx, audit_model.UserOAuth2ApplicationRemove, ctx.Doer, "oauth2_application", app.Name) + ctx.Status(http.StatusNoContent) } @@ -430,5 +456,7 @@ func UpdateOauth2Application(ctx *context.APIContext) { return } + audit.Record(ctx, audit_model.UserOAuth2ApplicationUpdate, ctx.Doer, "oauth2_application", app.Name) + ctx.JSON(http.StatusOK, convert.ToOAuth2Application(app)) } diff --git a/routers/common/audit.go b/routers/common/audit.go new file mode 100644 index 00000000000..8a3eb87274f --- /dev/null +++ b/routers/common/audit.go @@ -0,0 +1,26 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package common + +import ( + "net/http" + + audit_model "gitea.dev/models/audit" + "gitea.dev/modules/httplib" + "gitea.dev/modules/reqctx" + audit_service "gitea.dev/services/audit" +) + +// AuditOrigin publishes the origin and the client address of the request, so +// audit events recorded while serving it are attributed to it. +func AuditOrigin(origin audit_model.Origin) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + if store := reqctx.GetRequestDataStore(req.Context()); store != nil { + audit_service.SetRequestInfo(store, origin, httplib.RemoteHost(req)) + } + next.ServeHTTP(resp, req) + }) + } +} diff --git a/routers/common/auth.go b/routers/common/auth.go index adcc0902119..0520476f28a 100644 --- a/routers/common/auth.go +++ b/routers/common/auth.go @@ -5,6 +5,7 @@ package common import ( user_model "gitea.dev/models/user" + "gitea.dev/modules/log" "gitea.dev/modules/web/middleware" auth_service "gitea.dev/services/auth" "gitea.dev/services/context" @@ -30,6 +31,18 @@ func AuthShared(ctx *context.Base, sessionStore auth_service.SessionStore, authM ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer ctx.Data["SignedUserID"] = ar.Doer.ID ctx.Data["IsAdmin"] = ar.Doer.IsAdmin + + if sessionStore != nil { + if uid := auth_service.ImpersonatorUserID(sessionStore); uid != 0 { + impersonator, err := user_model.GetUserByID(ctx, uid) + if err != nil { + // the session stays usable, but audit events must not silently lose the admin behind it + log.Error("Unable to resolve impersonator %d: %v", uid, err) + } else { + ctx.Data[middleware.ContextDataKeyImpersonator] = impersonator + } + } + } } else { ctx.Data["SignedUserID"] = int64(0) } diff --git a/routers/install/install.go b/routers/install/install.go index c789f26d173..c97971460d0 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -15,6 +15,7 @@ import ( "strings" "time" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" db_install "gitea.dev/models/db/install" user_model "gitea.dev/models/user" @@ -30,6 +31,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/modules/web/middleware" "gitea.dev/routers/common" + "gitea.dev/services/audit" auth_service "gitea.dev/services/auth" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -508,6 +510,8 @@ func saveConfigAndRestart(ctx *context.Context, cfg setting.ConfigProvider, form u, _ = user_model.GetUserByName(ctx, u.Name) } + audit.RecordAs(ctx, u, audit_model.UserCreate, u) + nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID) if err != nil { ctx.ServerError("CreateAuthTokenForUserID", err) diff --git a/routers/private/hook_post_receive.go b/routers/private/hook_post_receive.go index 365c4eacc51..0d15feaaa48 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" + audit_model "gitea.dev/models/audit" git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" repo_model "gitea.dev/models/repo" @@ -19,6 +20,7 @@ import ( "gitea.dev/modules/timeutil" "gitea.dev/modules/util" "gitea.dev/modules/web" + "gitea.dev/services/audit" gitea_context "gitea.dev/services/context" pull_service "gitea.dev/services/pull" repo_service "gitea.dev/services/repository" @@ -154,6 +156,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts repo.IsPrivate = isPrivate.Value() if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil { log.Error("failed to update repo is_private: %v", err) + } else { + audit.RecordAs(ctx, ctx.Doer, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate) } } if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() { diff --git a/routers/private/internal.go b/routers/private/internal.go index 412c5017eb0..4c04c8e45eb 100644 --- a/routers/private/internal.go +++ b/routers/private/internal.go @@ -10,6 +10,7 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" "gitea.dev/modules/log" "gitea.dev/modules/private" "gitea.dev/modules/setting" @@ -74,6 +75,7 @@ func Routes() *web.Router { // Log the real ip address of the request from SSH is really helpful for diagnosing sometimes. // Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers. r.AfterRouting(setRealIP) + r.AfterRouting(common.AuditOrigin(audit_model.OriginSystem)) r.Get("/dummy", misc.DummyOK) r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent) diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go index 926c04f9c37..488ca672912 100644 --- a/routers/web/admin/applications.go +++ b/routers/web/admin/applications.go @@ -21,7 +21,7 @@ var ( func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers { return &user_setting.OAuth2CommonHandlers{ - OwnerID: 0, + Owner: nil, // instance-wide BasePathList: setting.AppSubURL + "/-/admin/applications", BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2", TplAppEdit: tplSettingsOauth2ApplicationEdit, diff --git a/routers/web/admin/audit.go b/routers/web/admin/audit.go new file mode 100644 index 00000000000..8950cb14f09 --- /dev/null +++ b/routers/web/admin/audit.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + "time" + + audit_model "gitea.dev/models/audit" + "gitea.dev/modules/httplib" + "gitea.dev/modules/log" + shared_audit "gitea.dev/routers/web/shared/audit" + "gitea.dev/services/audit" + "gitea.dev/services/context" +) + +const auditExportPageSize = 1000 + +func ViewAuditLogs(ctx *context.Context) { + shared_audit.View(ctx, shared_audit.ViewOptions{ + Template: "admin/audit/list", + PageData: map[string]any{"PageIsAdminMonitorAudit": true}, + }) +} + +func ExportAuditLogs(ctx *context.Context) { + // the export mirrors the filters of the listing it was started from + searchOpts := shared_audit.SearchOptionsFromRequest(ctx, "", 0) + searchOpts.Sort = audit_model.SortTimestampAsc + searchOpts.PageSize = auditExportPageSize + + page := 1 + findPage := func() ([]*audit_model.Event, int64, error) { + searchOpts.Page = page + return audit.FindEvents(ctx, searchOpts) + } + + // the first page is fetched before any header is written so a failing query still results in a proper error page + events, total, err := findPage() + if err != nil { + ctx.ServerError("FindEvents", err) + return + } + + httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{ + ContentType: "application/x-ndjson; charset=utf-8", + Filename: fmt.Sprintf("gitea-audit-log-%s.jsonl", time.Now().UTC().Format("20060102-150405Z")), + ContentDisposition: httplib.ContentDispositionAttachment, + }) + ctx.SetTotalCountHeader(total) // lets a client detect an export truncated by a mid-stream failure + ctx.Resp.WriteHeader(http.StatusOK) + + for { + if err := audit.WriteEventsAsJSON(ctx.Resp, events); err != nil { + log.Debug("Unable to write audit log export: %v", err) + return + } + if len(events) < auditExportPageSize { + return + } + + page++ + events, _, err = findPage() + if err != nil { + log.Error("Unable to continue audit log export: %v", err) + return + } + } +} diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index e0620dfc25a..551fed338ca 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -301,7 +301,7 @@ func NewAuthSourcePost(ctx *context.Context) { return } - if err := auth.CreateSource(ctx, &auth.Source{ + if err := auth_service.CreateSource(ctx, &auth.Source{ Type: auth.Type(form.Type), Name: form.Name, IsActive: form.IsActive, @@ -419,7 +419,7 @@ func EditAuthSourcePost(ctx *context.Context) { source.IsSyncEnabled = form.IsSyncEnabled source.Cfg = config source.TwoFactorPolicy = form.TwoFactorPolicy - if err := auth.UpdateSource(ctx, source); err != nil { + if err := auth_service.UpdateSource(ctx, source); err != nil { if errExist, ok := errors.AsType[auth.ErrSourceAlreadyExist](err); ok { ctx.Data["Err_Name"] = true ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_exist", errExist.Name), tplAuthEdit, form) diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go index a0ebc77392b..eed4ff1cce4 100644 --- a/routers/web/admin/emails.go +++ b/routers/web/admin/emails.go @@ -8,12 +8,14 @@ import ( "net/http" "net/url" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" user_model "gitea.dev/models/user" "gitea.dev/modules/log" "gitea.dev/modules/optional" "gitea.dev/modules/setting" "gitea.dev/modules/templates" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/user" ) @@ -129,6 +131,13 @@ func ActivateEmail(ctx *context.Context) { ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) } } else { + // the email already changed, so a failed lookup must not fail the request + if u, err := user_model.GetUserByID(ctx, uid); err != nil { + log.Error("GetUserByID(%d) for audit: %v", uid, err) + } else { + audit.Record(ctx, audit_model.UserEmailActivate, u, "email", email, "activated", activate) + } + log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate) ctx.Flash.Info(ctx.Tr("admin.emails.updated")) } diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index f1d65a6b5e4..eaf54484801 100644 --- a/routers/web/admin/hooks.go +++ b/routers/web/admin/hooks.go @@ -10,6 +10,7 @@ import ( "gitea.dev/modules/optional" "gitea.dev/modules/setting" "gitea.dev/modules/templates" + "gitea.dev/services/audit" "gitea.dev/services/context" ) @@ -61,9 +62,14 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { // DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook func DeleteDefaultOrSystemWebhook(ctx *context.Context) { - if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil { + hook, err := webhook.GetWebhookByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.Flash.Error("GetWebhookByID: " + err.Error()) + } else if err := webhook.DeleteDefaultSystemWebhook(ctx, hook.ID); err != nil { ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error()) } else { + audit.RecordScoped(ctx, nil, nil, audit.WebhookRemove, "webhook", hook.URL) + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 839ff70fb56..fadbe4692b0 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" "gitea.dev/models/db" org_model "gitea.dev/models/organization" @@ -25,6 +26,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/routers/web/explore" user_setting "gitea.dev/routers/web/user/setting" + "gitea.dev/services/audit" auth_service "gitea.dev/services/auth" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -203,6 +205,8 @@ func NewUserPost(ctx *context.Context) { ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email)) } + audit.Record(ctx, audit_model.UserCreate, u) + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) // Send email notification. @@ -468,6 +472,7 @@ func ImpersonateUser(ctx *context.Context) { ctx.ServerError("unable to impersonate user", err) return } + audit.Record(ctx, audit_model.UserImpersonation, u) ctx.JSONRedirect(setting.AppSubURL + "/user/settings") } diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index e4c19c0d9a4..cebc512f3c8 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -7,12 +7,14 @@ import ( "errors" "net/http" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" user_model "gitea.dev/models/user" "gitea.dev/modules/session" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -82,6 +84,10 @@ func TwoFactorPost(ctx *context.Context) { return } + if u, err := user_model.GetUserByID(ctx, id); err == nil { + audit.RecordAs(ctx, u, audit_model.UserAuthenticationFailTwoFactor, u) + } + ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{}) } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 0256424d84a..e29d61eff95 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -13,6 +13,7 @@ import ( "net/url" "strings" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" "gitea.dev/models/db" user_model "gitea.dev/models/user" @@ -27,6 +28,7 @@ import ( "gitea.dev/modules/util" "gitea.dev/modules/web" "gitea.dev/modules/web/middleware" + "gitea.dev/services/audit" auth_service "gitea.dev/services/auth" "gitea.dev/services/auth/source/oauth2" "gitea.dev/services/context" @@ -453,12 +455,15 @@ func SignOut(ctx *context.Context) { websocket_service.PublishLogout(ctx.Doer.ID, ctx.Session.ID()) } + impersonator := audit.ImpersonatorFromContext(ctx) + exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session) if err != nil { ctx.ServerError("ExitImpersonatedUser", err) return } if exitedImpersonated { + audit.RecordAs(ctx, impersonator, audit_model.UserImpersonationExit, ctx.Doer) ctx.Redirect(setting.AppSubURL + "/-/admin") return } diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 88ac21e8020..0b3aeb865b4 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -26,6 +26,7 @@ import ( "gitea.dev/modules/proxy" "gitea.dev/modules/session" "gitea.dev/modules/setting" + "gitea.dev/services/audit" source_service "gitea.dev/services/auth/source" "gitea.dev/services/auth/source/oauth2" "gitea.dev/services/context" @@ -419,7 +420,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m // Register last login opts.SetLastLogin = true - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser()), u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } @@ -448,7 +449,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m } if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() { - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser()), u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } diff --git a/routers/web/auth/oauth2_provider.go b/routers/web/auth/oauth2_provider.go index 8da6cd34d66..69fa9dcabdf 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -12,6 +12,7 @@ import ( "net/url" "strconv" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" user_model "gitea.dev/models/user" "gitea.dev/modules/auth/httpauth" @@ -20,6 +21,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/web" + "gitea.dev/services/audit" auth_service "gitea.dev/services/auth" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -120,7 +122,7 @@ func InfoOAuth(ctx *context.Context) { var accessTokenScope auth.AccessTokenScope if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil { - accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token) + accessTokenScope, _, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token) } } @@ -427,6 +429,8 @@ func GrantApplicationOAuth(ctx *context.Context) { }, form.RedirectURI) return } + + audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope) } else if grant.Scope != form.Scope { handleAuthorizeError(ctx, AuthorizeError{ State: form.State, diff --git a/routers/web/auth/oauth_signin_sync.go b/routers/web/auth/oauth_signin_sync.go index f5ec66e006a..fcb84a7694c 100644 --- a/routers/web/auth/oauth_signin_sync.go +++ b/routers/web/auth/oauth_signin_sync.go @@ -12,6 +12,7 @@ import ( "gitea.dev/modules/log" "gitea.dev/modules/util" asymkey_service "gitea.dev/services/asymkey" + "gitea.dev/services/audit" "gitea.dev/services/auth/source/oauth2" "gitea.dev/services/context" user_service "gitea.dev/services/user" @@ -54,7 +55,7 @@ func oauth2SignInSync(ctx *context.Context, authSourceID int64, u *user_model.Us // sync user flags (admin/restricted) isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) if isAdmin.Has() || isRestricted.Has() { - if err = user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{IsAdmin: isAdmin, IsRestricted: isRestricted}); err != nil { + if err = user_service.UpdateUser(audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser()), u, &user_service.UpdateOptions{IsAdmin: isAdmin, IsRestricted: isRestricted}); err != nil { log.Error("Unable to sync OAuth2 user admin or restricted status %s: %v", gothUser.Provider, err) } } diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index bdc4a0396e1..e5bbf6a19cc 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" user_model "gitea.dev/models/user" "gitea.dev/modules/auth/password" @@ -16,6 +17,7 @@ import ( "gitea.dev/modules/templates" "gitea.dev/modules/timeutil" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" "gitea.dev/services/mailer" @@ -86,6 +88,10 @@ func ForgotPasswdPost(ctx *context.Context) { mailer.SendResetPasswordMail(u) + // the request is unauthenticated, so the affected account is the only actor + // we can name; the recorded IP address carries the forensic signal + audit.RecordAs(ctx, u, audit_model.UserPasswordResetRequest, u) + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { log.Error("Set cache(MailResendLimit) fail: %v", err) } diff --git a/routers/web/org/org.go b/routers/web/org/org.go index e217e74d74d..2d068005dd9 100644 --- a/routers/web/org/org.go +++ b/routers/web/org/org.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" "gitea.dev/models/organization" user_model "gitea.dev/models/user" @@ -15,6 +16,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -79,6 +81,8 @@ func CreatePost(ctx *context.Context) { } return } + audit.Record(ctx, audit_model.OrganizationCreate, org.AsUser()) + log.Trace("Organization created: %s", org.Name) ctx.Redirect(org.AsUser().DashboardLink()) diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 66dae6aa9fe..4cf346aef84 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" project_model "gitea.dev/models/project" @@ -19,6 +20,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/routers/web/shared/issue" shared_user "gitea.dev/routers/web/shared/user" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" project_service "gitea.dev/services/projects" @@ -171,6 +173,7 @@ func NewProjectPost(ctx *context.Context) { ctx.ServerError("NewProject", err) return } + audit.Record(ctx, audit_model.ProjectCreate, ctx.ContextUser, "project", newProject.Title, "project_id", newProject.ID) ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects") @@ -214,6 +217,7 @@ func DeleteProject(ctx *context.Context) { if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { ctx.Flash.Error("DeleteProjectByID: " + err.Error()) } else { + audit.Record(ctx, audit_model.ProjectDelete, ctx.ContextUser, "project", p.Title, "project_id", p.ID) ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) } @@ -284,6 +288,7 @@ func EditProjectPost(ctx *context.Context) { ctx.ServerError("UpdateProjects", err) return } + audit.Record(ctx, audit_model.ProjectUpdate, ctx.ContextUser, "project", p.Title, "project_id", p.ID) ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) if ctx.FormString("redirect") == "project" { diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 207d116dac8..2cd7f94859d 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -24,6 +24,7 @@ import ( "gitea.dev/modules/web" shared_user "gitea.dev/routers/web/shared/user" user_setting "gitea.dev/routers/web/user/setting" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" org_service "gitea.dev/services/org" @@ -172,9 +173,14 @@ func Webhooks(ctx *context.Context) { // DeleteWebhook response for delete webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + hook, err := webhook.GetWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")) + if err != nil { + ctx.Flash.Error("GetWebhookByOwnerID: " + err.Error()) + } else if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, hook.ID); err != nil { ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { + audit.RecordScoped(ctx, ctx.Org.Organization.AsUser(), nil, audit.WebhookRemove, "webhook", hook.URL) + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/org/setting/audit.go b/routers/web/org/setting/audit.go new file mode 100644 index 00000000000..73220a2a40d --- /dev/null +++ b/routers/web/org/setting/audit.go @@ -0,0 +1,22 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + audit_model "gitea.dev/models/audit" + shared_audit "gitea.dev/routers/web/shared/audit" + "gitea.dev/services/context" +) + +func ViewAuditLogs(ctx *context.Context) { + shared_audit.View(ctx, shared_audit.ViewOptions{ + Template: "org/settings/audit_logs", + ScopeType: audit_model.ScopeOrganization, + ScopeID: ctx.Org.Organization.ID, + PageData: map[string]any{ + "PageIsOrgSettings": true, + "PageIsSettingsAudit": true, + }, + }) +} diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go index 538f0c7d463..953a9c01c70 100644 --- a/routers/web/org/setting_oauth2.go +++ b/routers/web/org/setting_oauth2.go @@ -23,7 +23,7 @@ const ( func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers { return &user_setting.OAuth2CommonHandlers{ - OwnerID: org.Organization.ID, + Owner: org.Organization.AsUser(), BasePathList: fmt.Sprintf("%s/org/%s/settings/applications", setting.AppSubURL, org.Organization.Name), BasePathEditPrefix: fmt.Sprintf("%s/org/%s/settings/applications/oauth2", setting.AppSubURL, org.Organization.Name), TplAppEdit: tplSettingsOAuthApplicationEdit, diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 773176b0751..687f0d07138 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -1385,6 +1385,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) { ctx.ServerError("UpdateRepoUnit", err) return } + actions_service.RecordWorkflowToggle(ctx, ctx.Repo.Repository, workflow, isEnable) if isEnable { ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow)) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 69b0a7b34d6..ab1ec7bbd64 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -9,6 +9,7 @@ import ( "slices" "strings" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" project_model "gitea.dev/models/project" @@ -24,6 +25,7 @@ import ( "gitea.dev/modules/web" "gitea.dev/routers/web/shared/issue" shared_user "gitea.dev/routers/web/shared/user" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" project_service "gitea.dev/services/projects" @@ -134,7 +136,7 @@ func NewProjectPost(ctx *context.Context) { return } - if err := project_model.NewProject(ctx, &project_model.Project{ + project := &project_model.Project{ RepoID: ctx.Repo.Repository.ID, Title: form.Title, Description: form.Content, @@ -142,10 +144,12 @@ func NewProjectPost(ctx *context.Context) { TemplateType: form.TemplateType, CardType: form.CardType, Type: project_model.TypeRepository, - }); err != nil { + } + if err := project_model.NewProject(ctx, project); err != nil { ctx.ServerError("NewProject", err) return } + audit.Record(ctx, audit_model.ProjectCreate, ctx.Repo.Repository, "project", project.Title, "project_id", project.ID) ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) ctx.Redirect(ctx.Repo.RepoLink + "/projects") @@ -191,6 +195,7 @@ func DeleteProject(ctx *context.Context) { if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil { ctx.Flash.Error("DeleteProjectByID: " + err.Error()) } else { + audit.Record(ctx, audit_model.ProjectDelete, ctx.Repo.Repository, "project", p.Title, "project_id", p.ID) ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success")) } @@ -265,6 +270,7 @@ func EditProjectPost(ctx *context.Context) { ctx.ServerError("UpdateProjects", err) return } + audit.Record(ctx, audit_model.ProjectUpdate, ctx.Repo.Repository, "project", p.Title, "project_id", p.ID) ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title)) if ctx.FormString("redirect") == "project" { diff --git a/routers/web/repo/setting/audit.go b/routers/web/repo/setting/audit.go new file mode 100644 index 00000000000..1bacbf691e3 --- /dev/null +++ b/routers/web/repo/setting/audit.go @@ -0,0 +1,19 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + audit_model "gitea.dev/models/audit" + shared_audit "gitea.dev/routers/web/shared/audit" + "gitea.dev/services/context" +) + +func ViewAuditLogs(ctx *context.Context) { + shared_audit.View(ctx, shared_audit.ViewOptions{ + Template: "repo/settings/audit_logs", + ScopeType: audit_model.ScopeRepository, + ScopeID: ctx.Repo.Repository.ID, + PageData: map[string]any{"PageIsSettingsAudit": true}, + }) +} diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index 2ba5307be7e..d874bc77fa8 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -8,6 +8,7 @@ import ( "net/http" asymkey_model "gitea.dev/models/asymkey" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" deploykey_model "gitea.dev/models/deploykey" "gitea.dev/models/perm" @@ -15,6 +16,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/util" asymkey_service "gitea.dev/services/asymkey" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -68,6 +70,7 @@ func DeployKeysPost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryDeployKeyAdd, ctx.Repo.Repository, "deploy_key", key.Name) ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name)) ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys") } diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index 145ad5ebe17..045513a321c 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -12,6 +12,7 @@ import ( "strings" "time" + audit_model "gitea.dev/models/audit" git_model "gitea.dev/models/git" "gitea.dev/models/organization" "gitea.dev/models/perm" @@ -24,6 +25,7 @@ import ( "gitea.dev/modules/templates" "gitea.dev/modules/web" "gitea.dev/routers/web/repo" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" pull_service "gitea.dev/services/pull" @@ -149,8 +151,10 @@ func SettingsProtectedBranchPost(ctx *context.Context) { return } } + isNewProtectedBranch := false if protectBranch == nil { // No options found, create defaults. + isNewProtectedBranch = true protectBranch = &git_model.ProtectedBranch{ RepoID: ctx.Repo.Repository.ID, RuleName: f.RuleName, @@ -291,6 +295,12 @@ func SettingsProtectedBranchPost(ctx *context.Context) { return } + if isNewProtectedBranch { + audit.Record(ctx, audit_model.RepositoryBranchProtectionAdd, ctx.Repo.Repository, "rule", protectBranch.RuleName) + } else { + audit.Record(ctx, audit_model.RepositoryBranchProtectionUpdate, ctx.Repo.Repository, "rule", protectBranch.RuleName) + } + ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName)) ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName)) } @@ -323,6 +333,8 @@ func DeleteProtectedBranchRulePost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryBranchProtectionRemove, ctx.Repo.Repository, "rule", rule.RuleName) + ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName)) ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches") } diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go index b85476a146a..e3efb39848c 100644 --- a/routers/web/repo/setting/protected_tag.go +++ b/routers/web/repo/setting/protected_tag.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" git_model "gitea.dev/models/git" "gitea.dev/models/organization" "gitea.dev/models/perm" @@ -17,6 +18,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -65,6 +67,8 @@ func NewProtectedTagPost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryTagProtectionAdd, ctx.Repo.Repository, "pattern", pt.NamePattern) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) } @@ -118,6 +122,8 @@ func EditProtectedTagPost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryTagProtectionUpdate, ctx.Repo.Repository, "pattern", pt.NamePattern) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") } @@ -134,6 +140,8 @@ func DeleteProtectedTagPost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryTagProtectionRemove, ctx.Repo.Repository, "pattern", pt.NamePattern) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") } diff --git a/routers/web/repo/setting/secrets.go b/routers/web/repo/setting/secrets.go index 17675e56e79..5064fa7965d 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -7,6 +7,8 @@ import ( "errors" "net/http" + repo_model "gitea.dev/models/repo" + user_model "gitea.dev/models/user" "gitea.dev/modules/setting" "gitea.dev/modules/templates" shared "gitea.dev/routers/web/shared/secrets" @@ -22,6 +24,8 @@ const ( ) type secretsCtx struct { + Owner *user_model.User + Repo *repo_model.Repository OwnerID int64 RepoID int64 IsRepo bool @@ -34,7 +38,7 @@ type secretsCtx struct { func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { if ctx.Data["PageIsRepoSettings"] == true { return &secretsCtx{ - OwnerID: 0, + Repo: ctx.Repo.Repository, RepoID: ctx.Repo.Repository.ID, IsRepo: true, SecretsTemplate: tplRepoSecrets, @@ -48,8 +52,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError } return &secretsCtx{ + Owner: ctx.ContextUser, OwnerID: ctx.ContextUser.ID, - RepoID: 0, IsOrg: true, SecretsTemplate: tplOrgSecrets, RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets", @@ -58,8 +62,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { if ctx.Data["PageIsUserSettings"] == true { return &secretsCtx{ + Owner: ctx.Doer, OwnerID: ctx.Doer.ID, - RepoID: 0, IsUser: true, SecretsTemplate: tplUserSecrets, RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets", @@ -105,8 +109,8 @@ func SecretsPost(ctx *context.Context) { shared.PerformSecretsPost( ctx, - sCtx.OwnerID, - sCtx.RepoID, + sCtx.Owner, + sCtx.Repo, sCtx.RedirectLink, ) } @@ -119,8 +123,8 @@ func SecretsDelete(ctx *context.Context) { } shared.PerformSecretsDelete( ctx, - sCtx.OwnerID, - sCtx.RepoID, + sCtx.Owner, + sCtx.Repo, sCtx.RedirectLink, ) } diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 392c2e8527b..252f4fa9b1c 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -11,6 +11,7 @@ import ( "strings" "time" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" "gitea.dev/models/organization" access_model "gitea.dev/models/perm/access" @@ -31,6 +32,7 @@ import ( "gitea.dev/modules/web" repo_router "gitea.dev/routers/web/repo" actions_service "gitea.dev/services/actions" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" "gitea.dev/services/migrations" @@ -478,6 +480,8 @@ func handleSettingsPostPushMirrorRemove(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryMirrorPushRemove, repo, "mirror_id", m.ID, "remote_address", m.RemoteAddress) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(repo.Link() + "/settings") } @@ -542,6 +546,8 @@ func handleSettingsPostPushMirrorAdd(ctx *context.Context) { return } + audit.Record(ctx, audit_model.RepositoryMirrorPushAdd, repo, "mirror_id", m.ID, "remote_address", m.RemoteAddress) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(repo.Link() + "/settings") } @@ -724,6 +730,9 @@ func handleSettingsPostSigning(ctx *context.Context) { ctx.ServerError("UpdateRepositoryColsNoAutoTime", err) return } + + audit.Record(ctx, audit_model.RepositorySigningVerification, repo, "trust_model", repo.TrustModel.String()) + log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) } @@ -808,6 +817,9 @@ func handleSettingsPostConvert(ctx *context.Context) { ctx.ServerError("DeleteMirrorByRepoID", err) return } + + audit.Record(ctx, audit_model.RepositoryConvertMirror, repo) + log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) ctx.JSONRedirect(repo.Link()) @@ -1018,6 +1030,8 @@ func handleSettingsPostArchive(ctx *context.Context) { // update issue indexer issue_indexer.UpdateRepoIndexer(ctx, repo.ID) + audit.Record(ctx, audit_model.RepositoryArchive, repo) + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) @@ -1046,6 +1060,8 @@ func handleSettingsPostUnarchive(ctx *context.Context) { // update issue indexer issue_indexer.UpdateRepoIndexer(ctx, repo.ID) + audit.Record(ctx, audit_model.RepositoryUnarchive, repo) + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 7bd198482c9..7fc8a4b6dcb 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -15,6 +15,7 @@ import ( "gitea.dev/models/db" "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/models/webhook" "gitea.dev/modules/git" @@ -25,6 +26,7 @@ import ( "gitea.dev/modules/util" "gitea.dev/modules/web" webhook_module "gitea.dev/modules/webhook" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/convert" "gitea.dev/services/forms" @@ -58,6 +60,8 @@ func Webhooks(ctx *context.Context) { } type ownerRepoCtx struct { + Owner *user_model.User + Repo *repo_model.Repository OwnerID int64 RepoID int64 IsAdmin bool @@ -71,6 +75,7 @@ type ownerRepoCtx struct { func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { if ctx.Data["PageIsRepoSettings"] == true { return &ownerRepoCtx{ + Repo: ctx.Repo.Repository, RepoID: ctx.Repo.Repository.ID, Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"), LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"), @@ -80,6 +85,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { if ctx.Data["PageIsOrgSettings"] == true { return &ownerRepoCtx{ + Owner: ctx.ContextUser, OwnerID: ctx.ContextUser.ID, Link: path.Join(ctx.Org.OrgLink, "settings/hooks"), LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"), @@ -89,6 +95,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { if ctx.Data["PageIsUserSettings"] == true { return &ownerRepoCtx{ + Owner: ctx.Doer, OwnerID: ctx.Doer.ID, Link: path.Join(setting.AppSubURL, "/user/settings/hooks"), LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"), @@ -109,6 +116,14 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { return nil, errors.New("unable to set OwnerRepo context") } +// recordWebhookAudit emits a webhook audit event scoped to the repository, +// organization, user, or instance (admin/system) the webhook belongs to. The +// shared add/edit handlers run in any of these contexts, so the scope is derived +// from orCtx rather than assuming a repository. +func (orCtx *ownerRepoCtx) recordWebhookAudit(ctx *context.Context, actions audit.ScopedActions, url string) { + audit.RecordScoped(ctx, orCtx.Owner, orCtx.Repo, actions, "webhook", url) +} + func checkHookType(ctx *context.Context) string { hookType := strings.ToLower(ctx.PathParam("type")) if !util.SliceContainsString(setting.Webhook.Types, hookType, true) { @@ -258,6 +273,8 @@ func createWebhook(ctx *context.Context, params webhookParams) { return } + orCtx.recordWebhookAudit(ctx, audit.WebhookAdd, w.URL) + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) ctx.Redirect(orCtx.Link) } @@ -311,6 +328,8 @@ func editWebhook(ctx *context.Context, params webhookParams) { return } + orCtx.recordWebhookAudit(ctx, audit.WebhookUpdate, w.URL) + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) } @@ -736,9 +755,14 @@ func ReplayWebhook(ctx *context.Context) { // DeleteWebhook delete a webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { + hook, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")) + if err != nil { + ctx.Flash.Error("GetWebhookByRepoID: " + err.Error()) + } else if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, hook.ID); err != nil { ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error()) } else { + audit.RecordScoped(ctx, nil, ctx.Repo.Repository, audit.WebhookRemove, "webhook", hook.URL) + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/shared/audit/audit.go b/routers/web/shared/audit/audit.go new file mode 100644 index 00000000000..3d1045442ce --- /dev/null +++ b/routers/web/shared/audit/audit.go @@ -0,0 +1,105 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "maps" + "net/http" + "slices" + + audit_model "gitea.dev/models/audit" + "gitea.dev/models/db" + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + "gitea.dev/modules/templates" + "gitea.dev/modules/util" + "gitea.dev/services/audit" + "gitea.dev/services/context" +) + +// ViewOptions configures a scoped audit log listing. The admin view leaves +// ScopeType empty to list every event; the user/org/repo views constrain the +// query to their own scope. +type ViewOptions struct { + Template templates.TplName + ScopeType audit_model.ScopeType + ScopeID int64 + // PageData holds ctx.Data flags to enable for the active navigation tab. + PageData map[string]any +} + +var filterableOrigins = []audit_model.Origin{audit_model.OriginUI, audit_model.OriginAPI, audit_model.OriginCLI, audit_model.OriginSystem} + +// SearchOptionsFromRequest builds the event query from the request filters and +// publishes the applied values to ctx.Data so the filter form can render its +// current state. The listing and the export share it to stay in sync. +func SearchOptionsFromRequest(ctx *context.Context, scopeType audit_model.ScopeType, scopeID int64) *audit_model.EventSearchOptions { + // only the two known sort values are accepted, anything else falls back to the default + sort := util.Iif(audit_model.EventSort(ctx.FormString("sort")) == audit_model.SortTimestampAsc, audit_model.SortTimestampAsc, audit_model.SortTimestampDesc) + + opts := &audit_model.EventSearchOptions{ + Sort: sort, + ScopeType: scopeType, + ScopeID: scopeID, + } + + if action := audit_model.Action(ctx.FormString("action")); action != "" { + if _, ok := audit_model.MessageTemplate(action); ok { + opts.Action = action + } else if audit_model.IsActionFilter(action) { + opts.ActionPrefix = action + } + } + if origin := audit_model.Origin(ctx.FormString("origin")); slices.Contains(filterableOrigins, origin) { + opts.Origin = origin + } + if actor := ctx.FormTrim("actor"); actor != "" { + u, err := user_model.GetUserByName(ctx, actor) + if err != nil { + opts.ActorID = -1 // an unknown actor matches nothing rather than everything + } else { + opts.ActorID = u.ID + } + } + + ctx.Data["AuditSort"] = string(opts.Sort) + ctx.Data["AuditFilterAction"] = string(opts.Action) + if opts.ActionPrefix != "" { + ctx.Data["AuditFilterAction"] = string(opts.ActionPrefix) + } + ctx.Data["AuditFilterOrigin"] = string(opts.Origin) + ctx.Data["AuditFilterActor"] = ctx.FormTrim("actor") + ctx.Data["AuditActions"] = audit_model.ActionFilters() + ctx.Data["AuditOrigins"] = filterableOrigins + + return opts +} + +// View renders a paginated audit log listing shared by the admin, user, org and +// repo settings pages. Only the scope filter, template and page flags differ. +func View(ctx *context.Context, opts ViewOptions) { + ctx.Data["Title"] = ctx.Tr("audit.title") + ctx.Data["AuditRecordEnabled"] = setting.AuditRecordEnabled() + maps.Copy(ctx.Data, opts.PageData) + + page := max(ctx.FormInt("page"), 1) + + searchOpts := SearchOptionsFromRequest(ctx, opts.ScopeType, opts.ScopeID) + searchOpts.ListOptions = db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.NoticePagingNum, + } + + evs, total, err := audit.FindEvents(ctx, searchOpts) + if err != nil { + ctx.ServerError("FindEvents", err) + return + } + + ctx.Data["AuditEvents"] = evs + + ctx.Data["Page"] = context.NewPagerBuilder(ctx).TotalCount(total).PerPageLimit(setting.UI.Admin.NoticePagingNum).CurPage(page).Build() + + ctx.HTML(http.StatusOK, opts.Template) +} diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index 6ad48492d44..88c2cb4ca48 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -5,10 +5,13 @@ package secrets import ( "gitea.dev/models/db" + repo_model "gitea.dev/models/repo" secret_model "gitea.dev/models/secret" + user_model "gitea.dev/models/user" "gitea.dev/modules/log" "gitea.dev/modules/util" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" secret_service "gitea.dev/services/secrets" @@ -26,29 +29,49 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { ctx.Data["DescriptionMaxLength"] = secret_model.SecretDescriptionMaxLength } -func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { - form := web.GetForm[*forms.AddSecretForm](ctx) +func secretOwnerRepoIDs(owner *user_model.User, repo *repo_model.Repository) (ownerID, repoID int64) { + if owner != nil { + ownerID = owner.ID + } + if repo != nil { + repoID = repo.ID + } + return ownerID, repoID +} - s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description) +func PerformSecretsPost(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository, redirectURL string) { + form := web.GetForm[*forms.AddSecretForm](ctx) + ownerID, repoID := secretOwnerRepoIDs(owner, repo) + + s, created, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description) if err != nil { ctx.JSONErrorAuto(err) return } + actions := audit.SecretUpdate + if created { + actions = audit.SecretAdd + } + audit.RecordScoped(ctx, owner, repo, actions, "secret", s.Name) + ctx.Flash.Success(ctx.Tr("secrets.save_success", s.Name)) ctx.JSONRedirect(redirectURL) } -func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { +func PerformSecretsDelete(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository, redirectURL string) { id := ctx.FormInt64("id") + ownerID, repoID := secretOwnerRepoIDs(owner, repo) - err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id) + s, err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id) if err != nil { log.Error("DeleteSecretByID(%d) failed: %v", id, err) ctx.JSONError(ctx.Tr("secrets.deletion.failed")) return } + audit.RecordScoped(ctx, owner, repo, audit.SecretRemove, "secret", s.Name) + ctx.Flash.Success(ctx.Tr("secrets.deletion.success")) ctx.JSONRedirect(redirectURL) } diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index b13b1718ce0..fc75a99f561 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -8,12 +8,14 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/util" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -104,6 +106,8 @@ func ApplicationsPost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.Doer, "token", t.Name, "token_scope", t.Scope) + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) ctx.Flash.Info(t.Token) @@ -112,9 +116,14 @@ func ApplicationsPost(ctx *context.Context) { // DeleteApplication response for delete user access token func DeleteApplication(ctx *context.Context) { - if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { + t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID) + if err != nil { + ctx.Flash.Error("GetAccessTokenByID: " + err.Error()) + } else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.Doer.ID); err != nil { ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) } else { + audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.Doer, "token", t.Name) + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) } diff --git a/routers/web/user/setting/audit.go b/routers/web/user/setting/audit.go new file mode 100644 index 00000000000..058950e958d --- /dev/null +++ b/routers/web/user/setting/audit.go @@ -0,0 +1,19 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + audit_model "gitea.dev/models/audit" + shared_audit "gitea.dev/routers/web/shared/audit" + "gitea.dev/services/context" +) + +func ViewAuditLogs(ctx *context.Context) { + shared_audit.View(ctx, shared_audit.ViewOptions{ + Template: "user/settings/audit_logs", + ScopeType: audit_model.ScopeUser, + ScopeID: ctx.Doer.ID, + PageData: map[string]any{"PageIsSettingsAudit": true}, + }) +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index de843e97f33..98d88eb154f 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -9,12 +9,14 @@ import ( "net/http" asymkey_model "gitea.dev/models/asymkey" + audit_model "gitea.dev/models/audit" "gitea.dev/models/db" user_model "gitea.dev/models/user" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/web" asymkey_service "gitea.dev/services/asymkey" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -68,7 +70,8 @@ func KeysPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/keys") return } - if _, err = asymkey_service.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0); err != nil { + key, err := asymkey_service.AddPrincipalKey(ctx, ctx.Doer.ID, content, 0) + if err != nil { ctx.Data["HasPrincipalError"] = true switch { case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err): @@ -81,6 +84,9 @@ func KeysPost(ctx *context.Context) { } return } + + audit.Record(ctx, audit_model.UserKeyPrincipalAdd, ctx.Doer, "key", key.Name) + ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "gpg": @@ -135,6 +141,8 @@ func KeysPost(ctx *context.Context) { for _, key := range keys { keyIDs += key.KeyID keyIDs += ", " + + audit.Record(ctx, audit_model.UserKeyGPGAdd, ctx.Doer, "gpg_key_id", key.KeyID) } if len(keyIDs) > 0 { keyIDs = keyIDs[:len(keyIDs)-2] @@ -189,7 +197,8 @@ func KeysPost(ctx *context.Context) { return } - if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0, false); err != nil { + key, err := asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0, false) + if err != nil { ctx.Data["HasSSHError"] = true switch { case asymkey_model.IsErrKeyAlreadyExist(err): @@ -210,6 +219,9 @@ func KeysPost(ctx *context.Context) { } return } + + audit.Record(ctx, audit_model.UserKeySSHAdd, ctx.Doer, "fingerprint", key.Fingerprint) + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "verify_ssh": @@ -256,10 +268,18 @@ func DeleteKey(ctx *context.Context) { ctx.JSONError("gpg keys setting is not allowed to be visited") return } + key, err := asymkey_model.GetGPGKeyForUserByID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) + if err != nil && !asymkey_model.IsErrGPGKeyNotExist(err) { + ctx.ServerError("GetGPGKeyForUserByID", err) + return + } if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { ctx.JSONError("Failed to delete PGP key") return } + if key != nil { + audit.Record(ctx, audit_model.UserKeyGPGRemove, ctx.Doer, "gpg_key_id", key.KeyID) + } ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) case "ssh": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go index 9a26353bc27..696c1eeace7 100644 --- a/routers/web/user/setting/oauth2.go +++ b/routers/web/user/setting/oauth2.go @@ -4,6 +4,7 @@ package setting import ( + user_model "gitea.dev/models/user" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/services/context" @@ -13,9 +14,9 @@ const ( tplSettingsOAuthApplicationEdit templates.TplName = "user/settings/applications_oauth2_edit" ) -func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers { +func newOAuth2CommonHandlers(owner *user_model.User) *OAuth2CommonHandlers { return &OAuth2CommonHandlers{ - OwnerID: userID, + Owner: owner, BasePathList: setting.AppSubURL + "/user/settings/applications", BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2", TplAppEdit: tplSettingsOAuthApplicationEdit, @@ -27,7 +28,7 @@ func OAuthApplicationsPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings_title") ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa := newOAuth2CommonHandlers(ctx.Doer) oa.AddApp(ctx) } @@ -36,7 +37,7 @@ func OAuthApplicationsEdit(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings_title") ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa := newOAuth2CommonHandlers(ctx.Doer) oa.EditSave(ctx) } @@ -45,24 +46,24 @@ func OAuthApplicationsRegenerateSecret(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings_title") ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa := newOAuth2CommonHandlers(ctx.Doer) oa.RegenerateSecret(ctx) } // OAuth2ApplicationShow displays the given application func OAuth2ApplicationShow(ctx *context.Context) { - oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa := newOAuth2CommonHandlers(ctx.Doer) oa.EditShow(ctx) } // DeleteOAuth2Application deletes the given oauth2 application func DeleteOAuth2Application(ctx *context.Context) { - oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa := newOAuth2CommonHandlers(ctx.Doer) oa.DeleteApp(ctx) } // RevokeOAuth2Grant revokes the grant with the given id func RevokeOAuth2Grant(ctx *context.Context) { - oa := newOAuth2CommonHandlers(ctx.Doer.ID) + oa := newOAuth2CommonHandlers(ctx.Doer) oa.RevokeGrant(ctx) } diff --git a/routers/web/user/setting/oauth2_common.go b/routers/web/user/setting/oauth2_common.go index f2226adfed4..86dd41fce3a 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -8,21 +8,36 @@ import ( "net/http" "gitea.dev/models/auth" + user_model "gitea.dev/models/user" "gitea.dev/modules/templates" "gitea.dev/modules/util" "gitea.dev/modules/web" shared_user "gitea.dev/routers/web/shared/user" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) type OAuth2CommonHandlers struct { - OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID + Owner *user_model.User // nil for instance-wide, otherwise the Org or User owning the applications BasePathList string // the base URL for the application list page, eg: "/user/setting/applications" BasePathEditPrefix string // the base URL for the application edit page, will be appended with app id, eg: "/user/setting/applications/oauth2" TplAppEdit templates.TplName // the template for the application edit page } +func (oa *OAuth2CommonHandlers) ownerID() int64 { + if oa.Owner != nil { + return oa.Owner.ID + } + return 0 +} + +// recordAudit emits an OAuth2 application audit event scoped to the owner, which +// is nil for instance-wide (admin) applications, an organization, or a user. +func (oa *OAuth2CommonHandlers) recordAudit(ctx *context.Context, actions audit.ScopedActions, appName string) { + audit.RecordScoped(ctx, oa.Owner, nil, actions, "oauth2_application", appName) +} + func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context, app *auth.OAuth2Application) { ctx.Data["App"] = app ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID) @@ -50,7 +65,7 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{ Name: form.Name, RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), - UserID: oa.OwnerID, + UserID: oa.ownerID(), ConfidentialClient: form.ConfidentialClient, SkipSecondaryAuthorization: form.SkipSecondaryAuthorization, }) @@ -59,6 +74,8 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { return } + oa.recordAudit(ctx, audit.OAuth2ApplicationAdd, app.Name) + // render the edit page with secret ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true) ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx) @@ -81,7 +98,7 @@ func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) { ctx.ServerError("GetOAuth2ApplicationByID", err) return } - if app.UID != oa.OwnerID { + if app.UID != oa.ownerID() { ctx.NotFound(nil) return } @@ -102,7 +119,7 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { ctx.ServerError("GetOAuth2ApplicationByID", err) return } - if app.UID != oa.OwnerID { + if app.UID != oa.ownerID() { ctx.NotFound(nil) return } @@ -110,18 +127,21 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { return } - var err error - if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ + updatedApp, err := auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ ID: ctx.PathParamInt64("id"), Name: form.Name, RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"), - UserID: oa.OwnerID, + UserID: oa.ownerID(), ConfidentialClient: form.ConfidentialClient, SkipSecondaryAuthorization: form.SkipSecondaryAuthorization, - }); err != nil { + }) + if err != nil { ctx.ServerError("UpdateOAuth2Application", err) return } + + oa.recordAudit(ctx, audit.OAuth2ApplicationUpdate, updatedApp.Name) + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) ctx.Redirect(oa.BasePathList) } @@ -137,7 +157,7 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { ctx.ServerError("GetOAuth2ApplicationByID", err) return } - if app.UID != oa.OwnerID { + if app.UID != oa.ownerID() { ctx.NotFound(nil) return } @@ -146,28 +166,59 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { ctx.ServerError("GenerateClientSecret", err) return } + + oa.recordAudit(ctx, audit.OAuth2ApplicationSecret, app.Name) + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true) oa.renderEditPage(ctx, app) } // DeleteApp deletes the given oauth2 application func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) { - if err := auth.DeleteOAuth2Application(ctx, ctx.PathParamInt64("id"), oa.OwnerID); err != nil { + app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.NotFoundOrServerError("GetOAuth2ApplicationByID", auth.IsErrOAuthApplicationNotFound, err) + return + } + + if err := auth.DeleteOAuth2Application(ctx, app.ID, oa.ownerID()); err != nil { ctx.ServerError("DeleteOAuth2Application", err) return } + oa.recordAudit(ctx, audit.OAuth2ApplicationRemove, app.Name) + ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success")) ctx.JSONRedirect(oa.BasePathList) } // RevokeGrant revokes the grant func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) { - if err := auth.RevokeOAuth2Grant(ctx, ctx.PathParamInt64("grantId"), oa.OwnerID); err != nil { + grant, err := auth.GetOAuth2GrantByID(ctx, ctx.PathParamInt64("grantId")) + if err != nil { + ctx.ServerError("GetOAuth2GrantByID", err) + return + } + // grants belong to individual users, so this also rejects the instance-wide + // (owner nil, ID 0) and organization handlers without assuming who routes here + if grant == nil || oa.Owner == nil || grant.UserID != oa.Owner.ID { + ctx.NotFound(nil) + return + } + + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err != nil { + ctx.NotFoundOrServerError("GetOAuth2ApplicationByID", auth.IsErrOAuthApplicationNotFound, err) + return + } + + if err := auth.RevokeOAuth2Grant(ctx, grant.ID, oa.ownerID()); err != nil { ctx.ServerError("RevokeOAuth2Grant", err) return } + oa.recordAudit(ctx, audit.OAuth2ApplicationRevoke, app.Name) + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) ctx.JSONRedirect(oa.BasePathList) } diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index 1b65ccb85f8..992df455ad8 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -12,12 +12,14 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" user_model "gitea.dev/models/user" "gitea.dev/modules/log" "gitea.dev/modules/session" "gitea.dev/modules/setting" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -57,6 +59,8 @@ func RegenerateScratchTwoFactor(ctx *context.Context) { return } + audit.Record(ctx, audit_model.UserTwoFactorRegenerate, ctx.Doer, "two_factor_id", t.ID) + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token)) ctx.Redirect(setting.AppSubURL + "/user/settings/security") } @@ -93,6 +97,8 @@ func DisableTwoFactor(ctx *context.Context) { return } + audit.Record(ctx, audit_model.UserTwoFactorDisable, ctx.Doer, "two_factor_id", t.ID) + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) ctx.Redirect(setting.AppSubURL + "/user/settings/security") } @@ -273,6 +279,8 @@ func EnrollTwoFactorPost(ctx *context.Context) { return } + audit.Record(ctx, audit_model.UserTwoFactorEnable, ctx.Doer) + ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token)) ctx.Redirect(setting.AppSubURL + "/user/settings/security") } diff --git a/routers/web/user/setting/security/openid.go b/routers/web/user/setting/security/openid.go index b2cf7b23b53..c4e7962380d 100644 --- a/routers/web/user/setting/security/openid.go +++ b/routers/web/user/setting/security/openid.go @@ -7,12 +7,14 @@ import ( "errors" "net/http" + audit_model "gitea.dev/models/audit" user_model "gitea.dev/models/user" "gitea.dev/modules/auth/openid" "gitea.dev/modules/log" "gitea.dev/modules/setting" "gitea.dev/modules/util" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" ) @@ -105,6 +107,9 @@ func settingsOpenIDVerify(ctx *context.Context) { return } log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name) + + audit.Record(ctx, audit_model.UserOpenIDAdd, ctx.Doer, "openid", oid.URI) + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) ctx.Redirect(setting.AppSubURL + "/user/settings/security") @@ -117,7 +122,17 @@ func DeleteOpenID(ctx *context.Context) { return } - if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil { + oid, err := user_model.GetUserOpenIDByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.HTTPError(http.StatusNotFound) + } else { + ctx.ServerError("GetUserOpenIDByID", err) + } + return + } + + if err := user_model.DeleteUserOpenID(ctx, oid); err != nil { if errors.Is(err, util.ErrNotExist) { ctx.HTTPError(http.StatusNotFound) } else { @@ -125,6 +140,9 @@ func DeleteOpenID(ctx *context.Context) { } return } + + audit.Record(ctx, audit_model.UserOpenIDRemove, ctx.Doer, "openid", oid.URI) + log.Trace("OpenID address deleted: %s", ctx.Doer.Name) ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success")) diff --git a/routers/web/user/setting/security/security.go b/routers/web/user/setting/security/security.go index e573289b1fb..c7132430fd7 100644 --- a/routers/web/user/setting/security/security.go +++ b/routers/web/user/setting/security/security.go @@ -8,12 +8,14 @@ import ( "net/http" "sort" + audit_model "gitea.dev/models/audit" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" user_model "gitea.dev/models/user" "gitea.dev/modules/optional" "gitea.dev/modules/setting" "gitea.dev/modules/templates" + "gitea.dev/services/audit" "gitea.dev/services/auth/source/oauth2" "gitea.dev/services/context" ) @@ -58,6 +60,8 @@ func DeleteAccountLink(ctx *context.Context) { if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil { ctx.Flash.Error("RemoveAccountLink: " + err.Error()) } else { + audit.Record(ctx, audit_model.UserExternalLoginRemove, ctx.Doer, "auth_source_id", id) + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) } } diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 4b1daeed211..da488ccdef7 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + audit_model "gitea.dev/models/audit" "gitea.dev/models/auth" user_model "gitea.dev/models/user" wa "gitea.dev/modules/auth/webauthn" @@ -16,6 +17,7 @@ import ( "gitea.dev/modules/session" "gitea.dev/modules/setting" "gitea.dev/modules/web" + "gitea.dev/services/audit" "gitea.dev/services/context" "gitea.dev/services/forms" @@ -124,13 +126,16 @@ func WebauthnRegisterPost(ctx *context.Context) { } // Create the credential - _, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred) + dbCred, err = auth.CreateCredential(ctx, ctx.Doer.ID, name, cred) if err != nil { ctx.ServerError("CreateCredential", err) return } _ = ctx.Session.Delete("webauthnName") _ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true) + + audit.Record(ctx, audit_model.UserWebAuthAdd, ctx.Doer, "credential", dbCred.Name) + ctx.JSON(http.StatusCreated, cred) } @@ -141,9 +146,17 @@ func WebauthnDelete(ctx *context.Context) { return } - if _, err := auth.DeleteCredential(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil { - ctx.ServerError("GetWebAuthnCredentialByID", err) + cred, err := auth.GetWebAuthnCredentialByID(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.NotFoundOrServerError("GetWebAuthnCredentialByID", auth.IsErrWebAuthnCredentialNotExist, err) return } + + if ok, err := auth.DeleteCredential(ctx, cred.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("DeleteCredential", err) + return + } else if ok { + audit.Record(ctx, audit_model.UserWebAuthRemove, ctx.Doer, "credential", cred.Name) + } ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") } diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go index 9f45f79bc07..b261853b370 100644 --- a/routers/web/user/setting/webhooks.go +++ b/routers/web/user/setting/webhooks.go @@ -10,6 +10,7 @@ import ( "gitea.dev/models/webhook" "gitea.dev/modules/setting" "gitea.dev/modules/templates" + "gitea.dev/services/audit" "gitea.dev/services/context" ) @@ -37,9 +38,14 @@ func Webhooks(ctx *context.Context) { // DeleteWebhook response for delete webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil { + hook, err := webhook.GetWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")) + if err != nil { + ctx.Flash.Error("GetWebhookByOwnerID: " + err.Error()) + } else if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, hook.ID); err != nil { ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { + audit.RecordScoped(ctx, ctx.Doer, nil, audit.WebhookRemove, "webhook", hook.URL) + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/web.go b/routers/web/web.go index f3ecc8ca2d3..8f05969494d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + audit_model "gitea.dev/models/audit" auth_model "gitea.dev/models/auth" "gitea.dev/models/perm" "gitea.dev/models/unit" @@ -33,6 +34,7 @@ import ( "gitea.dev/routers/web/healthcheck" "gitea.dev/routers/web/misc" "gitea.dev/routers/web/org" + org_setting "gitea.dev/routers/web/org/setting" "gitea.dev/routers/web/repo" "gitea.dev/routers/web/repo/actions" repo_setting "gitea.dev/routers/web/repo/setting" @@ -305,7 +307,7 @@ func Routes() *web.Router { routes.Get("/ssh_info", misc.SSHInfo) routes.Get("/api/healthz", healthcheck.Check) - mid = append(mid, common.MustInitSessioner(), context.Contexter()) + mid = append(mid, common.MustInitSessioner(), context.Contexter(), common.AuditOrigin(audit_model.OriginUI)) // Get user from session if logged in. webAuth := newWebAuthMiddleware() @@ -748,6 +750,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addWebhookEditRoutes() }, webhooksEnabled) + m.Get("/audit_logs", user_setting.ViewAuditLogs) + m.Group("/blocked_users", func() { m.Get("", user_setting.BlockedUsers) m.Post("", web.Bind[*forms.BlockUserForm](), user_setting.BlockedUsersPost) @@ -795,6 +799,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }) m.Group("/monitor", func() { + m.Get("/audit_logs", admin.ViewAuditLogs) + m.Get("/audit_logs/export", admin.ExportAuditLogs) m.Get("/stats", admin.MonitorStats) m.Get("/cron", admin.CronTasks) m.Get("/perftrace", admin.PerfTrace) @@ -1056,6 +1062,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { addSettingsScopedWorkflowsRoutes() }, actions.MustEnableActions) + m.Get("/audit_logs", org_setting.ViewAuditLogs) + m.Post("/rename", web.Bind[*forms.RenameOrgForm](), org.SettingsRenamePost) m.Post("/delete", org.SettingsDeleteOrgPost) m.Post("/visibility", org.SettingsChangeVisibilityPost) @@ -1267,6 +1275,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/token_permissions", repo_setting.UpdateTokenPermissions) }) }, actions.MustEnableActions) + m.Get("/audit_logs", repo_setting.ViewAuditLogs) // the follow handler must be under "settings", otherwise this incomplete repo can't be accessed m.Group("/migrate", func() { m.Post("/retry", repo.MigrateRetryPost) diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 7d0726f164f..46c0ef57ab6 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -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 } diff --git a/services/asymkey/deploy_key.go b/services/asymkey/deploy_key.go index 0a97bd2de2e..22f30f8a959 100644 --- a/services/asymkey/deploy_key.go +++ b/services/asymkey/deploy_key.go @@ -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 } diff --git a/services/asymkey/ssh_key.go b/services/asymkey/ssh_key.go index fc97a2e2aa1..8670a027eb9 100644 --- a/services/asymkey/ssh_key.go +++ b/services/asymkey/ssh_key.go @@ -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) } diff --git a/services/audit/actor.go b/services/audit/actor.go new file mode 100644 index 00000000000..0abcea34128 --- /dev/null +++ b/services/audit/actor.go @@ -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 +} diff --git a/services/audit/audit_test.go b/services/audit/audit_test.go new file mode 100644 index 00000000000..5dc8aa98f37 --- /dev/null +++ b/services/audit/audit_test.go @@ -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")) +} diff --git a/services/audit/event.go b/services/audit/event.go new file mode 100644 index 00000000000..3700aa1ecca --- /dev/null +++ b/services/audit/event.go @@ -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 +} diff --git a/services/audit/export.go b/services/audit/export.go new file mode 100644 index 00000000000..263423e78d7 --- /dev/null +++ b/services/audit/export.go @@ -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 +} diff --git a/services/audit/export_test.go b/services/audit/export_test.go new file mode 100644 index 00000000000..b0caf59c599 --- /dev/null +++ b/services/audit/export_test.go @@ -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"`) +} diff --git a/services/audit/main_test.go b/services/audit/main_test.go new file mode 100644 index 00000000000..e628abeb51d --- /dev/null +++ b/services/audit/main_test.go @@ -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) +} diff --git a/services/audit/message.go b/services/audit/message.go new file mode 100644 index 00000000000..473ea1a8069 --- /dev/null +++ b/services/audit/message.go @@ -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 +} diff --git a/services/audit/notifier.go b/services/audit/notifier.go new file mode 100644 index 00000000000..0acbbbb6aa2 --- /dev/null +++ b/services/audit/notifier.go @@ -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) +} diff --git a/services/audit/notifier_test.go b/services/audit/notifier_test.go new file mode 100644 index 00000000000..2f07970cfe3 --- /dev/null +++ b/services/audit/notifier_test.go @@ -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, + }) +} diff --git a/services/audit/refs.go b/services/audit/refs.go new file mode 100644 index 00000000000..f88ea20d91d --- /dev/null +++ b/services/audit/refs.go @@ -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() + } +} diff --git a/services/audit/scoped.go b/services/audit/scoped.go new file mode 100644 index 00000000000..fc8495ead06 --- /dev/null +++ b/services/audit/scoped.go @@ -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...), + }) +} diff --git a/services/audit/scoped_test.go b/services/audit/scoped_test.go new file mode 100644 index 00000000000..8c8f5699200 --- /dev/null +++ b/services/audit/scoped_test.go @@ -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) + }) +} diff --git a/services/auth/basic.go b/services/auth/basic.go index d562addfc60..cca8b85a44f 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -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 diff --git a/services/auth/credential.go b/services/auth/credential.go new file mode 100644 index 00000000000..381507e7ccc --- /dev/null +++ b/services/auth/credential.go @@ -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) +} diff --git a/services/auth/impersonate.go b/services/auth/impersonate.go index e16efe897d0..514072733a9 100644 --- a/services/auth/impersonate.go +++ b/services/auth/impersonate.go @@ -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 { diff --git a/services/auth/oauth2.go b/services/auth/oauth2.go index 0b2ac89d6d2..207e10ae2c4 100644 --- a/services/auth/oauth2.go +++ b/services/auth/oauth2.go @@ -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) } diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index 8a1c3535404..e05f3b933f2 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -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 } diff --git a/services/auth/source.go b/services/auth/source.go index 1b23f20e3cf..5c1335e9e3b 100644 --- a/services/auth/source.go +++ b/services/auth/source.go @@ -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 } diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index 86e45fc0976..90977c88bcf 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -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 diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index 63cf2c7d794..fe4f5a11231 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -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 { diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go index ee5e3eeeb43..c359349da72 100644 --- a/services/auth/source/pam/source_authenticate.go +++ b/services/auth/source/pam/source_authenticate.go @@ -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 } diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go index fbe3791bbab..b58ce53984f 100644 --- a/services/auth/source/smtp/source_authenticate.go +++ b/services/auth/source/smtp/source_authenticate.go @@ -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 } diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index d9c6ff7260c..d8784de6b79 100644 --- a/services/auth/source/source_group_sync.go +++ b/services/auth/source/source_group_sync.go @@ -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 { diff --git a/services/auth/sspi.go b/services/auth/sspi.go index 9aa43159dea..3d495c0441b 100644 --- a/services/auth/sspi.go +++ b/services/auth/sspi.go @@ -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 } diff --git a/services/context/access_log.go b/services/context/access_log.go index ecc6163a1b4..e22bc5896b4 100644 --- a/services/context/access_log.go +++ b/services/context/access_log.go @@ -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()) } diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 842d2621d39..3c6809480c6 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -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() diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go index 1dfc993539e..13b74720941 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -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 diff --git a/services/org/org.go b/services/org/org.go index 6da39eb6dc2..382cd0a6176 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -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. diff --git a/services/org/team.go b/services/org/team.go index b3db179f62c..4c4c7cb14e4 100644 --- a/services/org/team.go +++ b/services/org/team.go @@ -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 } diff --git a/services/org/user.go b/services/org/user.go index c518c4683e4..3e20a250ba0 100644 --- a/services/org/user.go +++ b/services/org/user.go @@ -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 } diff --git a/services/org/user_test.go b/services/org/user_test.go index 17d94ba643f..5394103a0ce 100644 --- a/services/org/user_test.go +++ b/services/org/user_test.go @@ -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, + }) +} diff --git a/services/repository/collaboration.go b/services/repository/collaboration.go index 46f31ae8e91..3c6fbfd2bfd 100644 --- a/services/repository/collaboration.go +++ b/services/repository/collaboration.go @@ -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 { diff --git a/services/repository/fork.go b/services/repository/fork.go index 64af9b9956d..462f811fb4b 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -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 { diff --git a/services/repository/repo_team.go b/services/repository/repo_team.go index 54706ebd11b..d44a5a2ba8d 100644 --- a/services/repository/repo_team.go +++ b/services/repository/repo_team.go @@ -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 diff --git a/services/repository/repository.go b/services/repository/repository.go index 7a0513c957b..aca267687e7 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -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) } } } diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 75288211d84..5c58d326e7e 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -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 } diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go index af7f8977b44..5cf6666bb18 100644 --- a/services/secrets/secrets.go +++ b/services/secrets/secrets.go @@ -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 { diff --git a/services/user/email.go b/services/user/email.go index 09939fa5dd0..529572cf38f 100644 --- a/services/user/email.go +++ b/services/user/email.go @@ -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 diff --git a/services/user/update.go b/services/user/update.go index 441e0217749..02660ac79ea 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -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 } diff --git a/services/user/user.go b/services/user/user.go index d03809d882a..cc1d359aa18 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -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 } diff --git a/services/user/user_test.go b/services/user/user_test.go index 3daf57639f9..5d275d71f94 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -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) { diff --git a/services/wiki/wiki.go b/services/wiki/wiki.go index a24583875a5..4a9f57f5420 100644 --- a/services/wiki/wiki.go +++ b/services/wiki/wiki.go @@ -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 } diff --git a/templates/admin/audit/list.tmpl b/templates/admin/audit/list.tmpl new file mode 100644 index 00000000000..6cc39e116e3 --- /dev/null +++ b/templates/admin/audit/list.tmpl @@ -0,0 +1,16 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monitor")}} +
+

+ {{ctx.Locale.Tr "audit.title"}} + + {{svg "octicon-download"}} {{ctx.Locale.Tr "audit.export"}} + +

+ {{if not .AuditRecordEnabled}} +
+ {{ctx.Locale.Tr "audit.disabled"}} +
+ {{end}} + {{template "shared/audit/list" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index a54fa530e24..b7c28d17e5a 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -101,9 +101,12 @@ {{ctx.Locale.Tr "admin.notices"}} -
+
{{ctx.Locale.Tr "admin.monitor"}}
{{end}} + + {{ctx.Locale.Tr "audit.title"}} + diff --git a/templates/repo/settings/audit_logs.tmpl b/templates/repo/settings/audit_logs.tmpl new file mode 100644 index 00000000000..446bfa0897d --- /dev/null +++ b/templates/repo/settings/audit_logs.tmpl @@ -0,0 +1,6 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings")}} +
+

{{ctx.Locale.Tr "audit.title"}}

+ {{template "shared/audit/list" .}} +
+{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index 36ee39bc07c..d24755d6ef2 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -57,5 +57,8 @@ {{end}}
+ + {{ctx.Locale.Tr "audit.title"}} + diff --git a/templates/shared/audit/list.tmpl b/templates/shared/audit/list.tmpl new file mode 100644 index 00000000000..f56c285297a --- /dev/null +++ b/templates/shared/audit/list.tmpl @@ -0,0 +1,89 @@ +
+
+ {{if .AuditSort}}{{end}} +
+ + {{template "shared/search/button"}} +
+
+ +
+
+ +
+
+
+
+ + + + + + + + + + + + + + {{range $event := .AuditEvents}} + + + + + + + + + + {{else}} + + {{end}} + +
{{ctx.Locale.Tr "audit.actor"}}{{ctx.Locale.Tr "audit.scope"}}{{ctx.Locale.Tr "audit.action"}}{{ctx.Locale.Tr "audit.origin"}}{{ctx.Locale.Tr "audit.details"}}{{ctx.Locale.Tr "audit.ip_address"}}{{ctx.Locale.Tr "audit.timestamp"}}{{SortArrow "timestamp_asc" "timestamp_desc" $.AuditSort false}}
+ {{with $impersonator := $event.Impersonator}} + {{if $impersonator.HasLink}} + {{$impersonator.DisplayName}} + {{else}} + {{$impersonator.DisplayName}} + {{end}} + {{ctx.Locale.Tr "audit.impersonating_as"}} + {{end}} + {{if $event.Actor.DisplayName}} + {{if $event.Actor.HasLink}} + {{$event.Actor.DisplayName}} + {{else}} + {{$event.Actor.DisplayName}} + {{end}} + {{else}} + {{ctx.Locale.Tr "audit.deleted.actor"}} + {{end}} + {{if $event.ActorCredential}} +
{{$event.ActorCredential}}
+ {{end}} +
+ {{if $event.Scope.DisplayName}} + {{if $event.Scope.HasLink}} + {{$event.Scope.DisplayName}} + {{else}} + {{$event.Scope.DisplayName}} + {{end}} + {{else}} + {{ctx.Locale.Tr "audit.deleted.type" $event.Scope.Type $event.Scope.ID}} + {{end}} + {{$event.Action}}{{$event.Origin}} + {{$event.Message}} + {{$event.IPAddress}}{{DateUtils.FullTime $event.Time}}
{{ctx.Locale.Tr "audit.no_events"}}
+
+{{template "base/paginate" .}} diff --git a/templates/user/settings/audit_logs.tmpl b/templates/user/settings/audit_logs.tmpl new file mode 100644 index 00000000000..6e8245db66b --- /dev/null +++ b/templates/user/settings/audit_logs.tmpl @@ -0,0 +1,6 @@ +{{template "user/settings/layout_head" (dict "ctxData" . "pageClass" "user settings audit")}} +
+

{{ctx.Locale.Tr "audit.title"}}

+ {{template "shared/audit/list" .}} +
+{{template "user/settings/layout_footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index c278105243d..40d7a2b14bf 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -33,6 +33,9 @@ {{ctx.Locale.Tr "settings.ssh_gpg_keys"}} {{end}} + + {{ctx.Locale.Tr "audit.title"}} + {{if .EnableActions}}
{{ctx.Locale.Tr "actions.actions"}} diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index 2ab5be5da58..fbb962dc0ad 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -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) }) } diff --git a/tests/integration/admin_audit_test.go b/tests/integration/admin_audit_test.go new file mode 100644 index 00000000000..2d3d2846d35 --- /dev/null +++ b/tests/integration/admin_audit_test.go @@ -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) + }) +} diff --git a/tests/integration/user_settings_oauth2_grant_test.go b/tests/integration/user_settings_oauth2_grant_test.go new file mode 100644 index 00000000000..c36be23e3b5 --- /dev/null +++ b/tests/integration/user_settings_oauth2_grant_test.go @@ -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) +}