diff --git a/cmd/admin_auth.go b/cmd/admin_auth.go index 4777a9290867c..f153d9fba8c6e 100644 --- a/cmd/admin_auth.go +++ b/cmd/admin_auth.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "errors" "fmt" "os" @@ -11,6 +12,8 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" auth_service "code.gitea.io/gitea/services/auth" "github.com/urfave/cli/v2" @@ -90,6 +93,26 @@ func runListAuth(c *cli.Context) error { return nil } +func createSource(ctx context.Context, source *auth_model.Source) error { + if err := auth_model.CreateSource(ctx, source); err != nil { + return err + } + + audit.RecordSystemAuthenticationSourceAdd(ctx, user_model.NewCLIUser(), source) + + return nil +} + +func updateSource(ctx context.Context, source *auth_model.Source) error { + if err := auth_model.UpdateSource(ctx, source); err != nil { + return err + } + + audit.RecordSystemAuthenticationSourceUpdate(ctx, user_model.NewCLIUser(), source) + + return nil +} + func runDeleteAuth(c *cli.Context) error { if !c.IsSet("id") { return errors.New("--id flag is missing") @@ -107,5 +130,5 @@ func runDeleteAuth(c *cli.Context) error { return err } - return auth_service.DeleteSource(ctx, source) + return auth_service.DeleteSource(ctx, user_model.NewCLIUser(), source) } diff --git a/cmd/admin_auth_ldap.go b/cmd/admin_auth_ldap.go index aff2a1285541c..a52ac9cdfd31e 100644 --- a/cmd/admin_auth_ldap.go +++ b/cmd/admin_auth_ldap.go @@ -9,6 +9,8 @@ import ( "strings" "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/auth/source/ldap" "github.com/urfave/cli/v2" @@ -308,58 +310,26 @@ func (a *authService) getAuthSource(ctx context.Context, c *cli.Context, authTyp // addLdapBindDn adds a new LDAP via Bind DN authentication source. func (a *authService) addLdapBindDn(c *cli.Context) error { - if err := argsSet(c, "name", "security-protocol", "host", "port", "user-search-base", "user-filter", "email-attribute"); err != nil { - return err - } - - ctx, cancel := installSignals() - defer cancel() - - if err := a.initDB(ctx); err != nil { - return err - } - - authSource := &auth.Source{ - Type: auth.LDAP, - IsActive: true, // active by default - Cfg: &ldap.Source{ - Enabled: true, // always true - }, - } - - parseAuthSource(c, authSource) - if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil { - return err - } - - return a.createAuthSource(ctx, authSource) + return a.addLdapSource(c, auth.LDAP, "name", "security-protocol", "host", "port", "user-search-base", "user-filter", "email-attribute") } // updateLdapBindDn updates a new LDAP via Bind DN authentication source. func (a *authService) updateLdapBindDn(c *cli.Context) error { - ctx, cancel := installSignals() - defer cancel() - - if err := a.initDB(ctx); err != nil { - return err - } - - authSource, err := a.getAuthSource(ctx, c, auth.LDAP) - if err != nil { - return err - } - - parseAuthSource(c, authSource) - if err := parseLdapConfig(c, authSource.Cfg.(*ldap.Source)); err != nil { - return err - } - - return a.updateAuthSource(ctx, authSource) + return a.updateLdapSource(c, auth.LDAP) } // addLdapSimpleAuth adds a new LDAP (simple auth) authentication source. func (a *authService) addLdapSimpleAuth(c *cli.Context) error { - if err := argsSet(c, "name", "security-protocol", "host", "port", "user-dn", "user-filter", "email-attribute"); err != nil { + return a.addLdapSource(c, auth.DLDAP, "name", "security-protocol", "host", "port", "user-dn", "user-filter", "email-attribute") +} + +// updateLdapBindDn updates a new LDAP (simple auth) authentication source. +func (a *authService) updateLdapSimpleAuth(c *cli.Context) error { + return a.updateLdapSource(c, auth.DLDAP) +} + +func (a *authService) addLdapSource(c *cli.Context, authType auth.Type, args ...string) error { + if err := argsSet(c, args...); err != nil { return err } @@ -369,9 +339,12 @@ func (a *authService) addLdapSimpleAuth(c *cli.Context) error { if err := a.initDB(ctx); err != nil { return err } + if err := audit.Init(); err != nil { + return err + } authSource := &auth.Source{ - Type: auth.DLDAP, + Type: authType, IsActive: true, // active by default Cfg: &ldap.Source{ Enabled: true, // always true @@ -383,19 +356,27 @@ func (a *authService) addLdapSimpleAuth(c *cli.Context) error { return err } - return a.createAuthSource(ctx, authSource) + if err := a.createAuthSource(ctx, authSource); err != nil { + return err + } + + audit.RecordSystemAuthenticationSourceAdd(ctx, user_model.NewCLIUser(), authSource) + + return nil } -// updateLdapSimpleAuth updates a new LDAP (simple auth) authentication source. -func (a *authService) updateLdapSimpleAuth(c *cli.Context) error { +func (a *authService) updateLdapSource(c *cli.Context, authType auth.Type) error { ctx, cancel := installSignals() defer cancel() if err := a.initDB(ctx); err != nil { return err } + if err := audit.Init(); err != nil { + return err + } - authSource, err := a.getAuthSource(ctx, c, auth.DLDAP) + authSource, err := a.getAuthSource(ctx, c, authType) if err != nil { return err } @@ -405,5 +386,11 @@ func (a *authService) updateLdapSimpleAuth(c *cli.Context) error { return err } - return a.updateAuthSource(ctx, authSource) + if err := a.updateAuthSource(ctx, authSource); err != nil { + return err + } + + audit.RecordSystemAuthenticationSourceUpdate(ctx, user_model.NewCLIUser(), authSource) + + return nil } diff --git a/cmd/admin_auth_oauth.go b/cmd/admin_auth_oauth.go index 8e6239ac33887..efc5709b711f0 100644 --- a/cmd/admin_auth_oauth.go +++ b/cmd/admin_auth_oauth.go @@ -184,7 +184,7 @@ func runAddOauth(c *cli.Context) error { } } - return auth_model.CreateSource(ctx, &auth_model.Source{ + return createSource(ctx, &auth_model.Source{ Type: auth_model.OAuth2, Name: c.String("name"), IsActive: true, @@ -295,5 +295,5 @@ func runUpdateOauth(c *cli.Context) error { oAuth2Config.CustomURLMapping = customURLMapping source.Cfg = oAuth2Config - return auth_model.UpdateSource(ctx, source) + return updateSource(ctx, source) } diff --git a/cmd/admin_auth_stmp.go b/cmd/admin_auth_stmp.go index d72474690521b..094d733b85e3e 100644 --- a/cmd/admin_auth_stmp.go +++ b/cmd/admin_auth_stmp.go @@ -155,7 +155,7 @@ func runAddSMTP(c *cli.Context) error { smtpConfig.Auth = "PLAIN" } - return auth_model.CreateSource(ctx, &auth_model.Source{ + return createSource(ctx, &auth_model.Source{ Type: auth_model.SMTP, Name: c.String("name"), IsActive: active, @@ -196,5 +196,5 @@ func runUpdateSMTP(c *cli.Context) error { source.Cfg = smtpConfig - return auth_model.UpdateSource(ctx, source) + return updateSource(ctx, source) } diff --git a/cmd/admin_user_change_password.go b/cmd/admin_user_change_password.go index f1ed46e70b083..3038d9bbc008a 100644 --- a/cmd/admin_user_change_password.go +++ b/cmd/admin_user_change_password.go @@ -62,7 +62,7 @@ func runChangePassword(c *cli.Context) error { Password: optional.Some(c.String("password")), MustChangePassword: optional.Some(c.Bool("must-change-password")), } - if err := user_service.UpdateAuth(ctx, user, opts); err != nil { + if err := user_service.UpdateAuth(ctx, user_model.NewCLIUser(), user, opts); err != nil { switch { case errors.Is(err, password.ErrMinLength): return fmt.Errorf("password is not long enough, needs to be at least %d characters", setting.MinPasswordLength) diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index 106d14b25a75f..3959756f89e2d 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -14,6 +14,7 @@ import ( pwd "code.gitea.io/gitea/modules/auth/password" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "github.com/urfave/cli/v2" ) @@ -103,6 +104,9 @@ func runCreateUser(c *cli.Context) error { return err } } + if err := audit.Init(); err != nil { + return err + } var password string if c.IsSet("password") { @@ -162,6 +166,8 @@ func runCreateUser(c *cli.Context) error { return fmt.Errorf("CreateUser: %w", err) } + audit.RecordUserCreate(ctx, user_model.NewCLIUser(), u) + if c.Bool("access-token") { t := &auth_model.AccessToken{ Name: "gitea-admin", @@ -172,6 +178,8 @@ func runCreateUser(c *cli.Context) error { return err } + audit.RecordUserAccessTokenAdd(ctx, user_model.NewCLIUser(), u, t) + fmt.Printf("Access token was successfully created... %s\n", t.Token) } diff --git a/cmd/admin_user_delete.go b/cmd/admin_user_delete.go index 520557554a215..c984d6cf9d126 100644 --- a/cmd/admin_user_delete.go +++ b/cmd/admin_user_delete.go @@ -10,6 +10,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/services/audit" user_service "code.gitea.io/gitea/services/user" "github.com/urfave/cli/v2" @@ -52,7 +53,9 @@ func runDeleteUser(c *cli.Context) error { if err := initDB(ctx); err != nil { return err } - + if err := audit.Init(); err != nil { + return err + } if err := storage.Init(); err != nil { return err } @@ -77,5 +80,5 @@ func runDeleteUser(c *cli.Context) error { return fmt.Errorf("The user %s does not match the provided id %d", user.Name, c.Int64("id")) } - return user_service.DeleteUser(ctx, user, c.Bool("purge")) + return user_service.DeleteUser(ctx, user_model.NewCLIUser(), user, c.Bool("purge")) } diff --git a/cmd/admin_user_generate_access_token.go b/cmd/admin_user_generate_access_token.go index 6c2c10494ee5f..c796ee6a5afeb 100644 --- a/cmd/admin_user_generate_access_token.go +++ b/cmd/admin_user_generate_access_token.go @@ -9,6 +9,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" "github.com/urfave/cli/v2" ) @@ -52,6 +53,9 @@ func runGenerateAccessToken(c *cli.Context) error { if err := initDB(ctx); err != nil { return err } + if err := audit.Init(); err != nil { + return err + } user, err := user_model.GetUserByName(ctx, c.String("username")) if err != nil { @@ -84,6 +88,8 @@ func runGenerateAccessToken(c *cli.Context) error { return err } + audit.RecordUserAccessTokenAdd(ctx, user_model.NewCLIUser(), user, t) + if c.Bool("raw") { fmt.Printf("%s\n", t.Token) } else { diff --git a/cmd/web.go b/cmd/web.go index ef8a7426c14d9..2460f9cf4ddc4 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -15,6 +15,8 @@ import ( _ "net/http/pprof" // Used for debugging if enabled and a web server is running + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" @@ -23,6 +25,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers/install" + "code.gitea.io/gitea/services/audit" "github.com/felixge/fgprof" "github.com/urfave/cli/v2" @@ -209,7 +212,13 @@ func serveInstalled(ctx *cli.Context) error { // Set up Chi routes webRoutes := routers.NormalRoutes() + + audit.RecordSystemStartup(db.DefaultContext, user_model.NewCLIUser(), setting.AppVer) + err := listen(webRoutes, true) + + audit.RecordSystemShutdown(db.DefaultContext, user_model.NewCLIUser()) + <-graceful.GetManager().Done() log.Info("PID: %d Gitea Web Finished", os.Getpid()) log.GetManager().Close() diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 5c23f70d7ca7a..d658f02209d17 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -673,6 +673,32 @@ LEVEL = Info ;; Host address ;ADDR = +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +[audit] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Enable logging of audit events +;ENABLED = false +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[audit.file] +;; Set the file name for the logger. If this is a relative path this +;; will be relative to log.ROOT_PATH +;; Defaults to log.ROOT_PATH/audit.log +;FILE_NAME = +;; This enables automated audit log rotate, default is true +;LOG_ROTATE = true +;; Maximum file size in bytes before rotating takes place (format `1000`, `1 MB`, `1 GiB`) +;MAXIMUM_SIZE = 256 MB +;; Rotate audit log daily, default is true +;DAILY_ROTATE = true +;; Delete the audit log file after n days, default is 7 +;MAX_DAYS = 7 +;; Compress audit logs with gzip +;COMPRESS = true +;; Compression level see godoc for compress/gzip +;COMPRESSION_LEVEL = -1 + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; [git] diff --git a/docs/content/administration/audit-logging.en-us.md b/docs/content/administration/audit-logging.en-us.md new file mode 100644 index 0000000000000..f4852e166af4c --- /dev/null +++ b/docs/content/administration/audit-logging.en-us.md @@ -0,0 +1,168 @@ +--- +date: "2023-04-21T00:00:00+00:00" +title: "Audit Logging" +slug: "audit-logging" +sidebar_position: 43 +toc: false +draft: false +menu: + sidebar: + parent: "administration" + name: "Audit Logging" + sidebar_position: 43 + identifier: "audit-logging" +--- + +# Audit Logging + +Audit logging is used to track security related events and provide documentary evidence of the sequence of important activities. + +**Table of Contents** + +{{< toc >}} + +## Appenders + +The audit log supports different appenders: + +- `log`: Log events as information to the configured Gitea logging +- `file`: Write events as JSON objects to a file + +The config documentation lists all available options to configure audit logging with appenders. + +## Events + +Audit events are grouped by `user`, `organization`, `repository` and `system`. + +### User Events + +| Event | Description | +| - | - | +| `user:impersonation` | Admin impersonating user | +| `user:create` | Created user | +| `user:delete` | Deleted user | +| `user:authentication:fail:twofactor` | Failed two-factor authentication for user | +| `user:authentication:source` | Changed authentication source of user | +| `user:active` | Changed activation status of user | +| `user:restricted` | Changed restriction status of user | +| `user:admin` | Changed admin status of user | +| `user:name` | Changed user name | +| `user:password` | Changed password of user | +| `user:password:resetrequest` | Requested a password reset | +| `user:visibility` | Changed visibility of user | +| `user:email:primary` | Changed primary email of user | +| `user:email:add` | Added email to user | +| `user:email:activate` | Activated email of user | +| `user:email:remove` | Removed email from user | +| `user:twofactor:enable` | User enabled two-factor authentication | +| `user:twofactor:regenerate` | User regenerated two-factor authentication secret | +| `user:twofactor:disable` | User disabled two-factor authentication | +| `user:webauth:add` | User added WebAuthn key | +| `user:webauth:remove` | User removed WebAuthn key | +| `user:externallogin:add` | Added external login for user | +| `user:externallogin:remove` | Removed external login for user | +| `user:openid:add` | Associated OpenID to user | +| `user:openid:remove` | Removed OpenID from user | +| `user:accesstoken:add` | Added access token for user | +| `user:accesstoken:remove` | Removed access token from user | +| `user:oauth2application:add` | Created OAuth2 application | +| `user:oauth2application:update` | Updated OAuth2 application | +| `user:oauth2application:secret` | Regenerated secret for OAuth2 application | +| `user:oauth2application:grant` | Granted OAuth2 access to application | +| `user:oauth2application:revoke` | Revoked OAuth2 grant for application | +| `user:oauth2application:remove` | Removed OAuth2 application | +| `user:key:ssh:add` | Added SSH key | +| `user:key:ssh:remove` | Removed SSH key | +| `user:key:principal:add` | Added principal key | +| `user:key:principal:remove` | Removed principal key | +| `user:key:gpg:add` | Added GPG key | +| `user:key:gpg:remove` | Added GPG key | +| `user:secret:add` | Added secret | +| `user:secret:update` | Updated secret | +| `user:secret:remove` | Removed secret | +| `user:webhook:add` | Added webhook | +| `user:webhook:update` | Updated webhook | +| `user:webhook:remove` | Removed webhook | + +### Organization Events + +| Event | Description | +| - | - | +| `organization:create` | Created organization | +| `organization:delete` | Deleted organization | +| `organization:name` | Changed organization name | +| `organization:visibility` | Changed visibility of organization | +| `organization:team:add` | Added team to organization | +| `organization:team:update` | Updated settings of team | +| `organization:team:remove` | Removed team from organization | +| `organization:team:permission` | Changed permission of team | +| `organization:team:member:add` | Added user to team | +| `organization:team:member:remove` | Removed User from team | +| `organization:oauth2application:add` | Created OAuth2 application | +| `organization:oauth2application:update` | Updated OAuth2 application | +| `organization:oauth2application:secret` | Regenerated secret for OAuth2 application | +| `organization:oauth2application:remove` | Removed OAuth2 application | +| `organization:secret:add` | Added secret | +| `organization:secret:update` | Updated secret | +| `organization:secret:remove` | Removed secret | +| `organization:webhook:add` | Added webhook | +| `organization:webhook:update` | Updated webhook | +| `organization:webhook:remove` | Removed webhook | + +### Repository Events + +| Event | Description | +| - | - | +| `repository:create` | Crated repository | +| `repository:create:fork` | Created fork of repository | +| `repository:archive` | Archived repository | +| `repository:unarchive` | Unarchived repository | +| `repository:delete` | Deleted repository | +| `repository:name` | Changed repository name | +| `repository:visibility` | Changed visibility of repository | +| `repository:convert:fork` | Converted repository from fork to regular repository | +| `repository:convert:mirror` | Converted repository from mirror to regular repository | +| `repository:mirror:push:add` | Added push mirror for repository | +| `repository:mirror:push:remove` | Removed push mirror from repository | +| `repository:signingverification` | Changed signing verification of repository | +| `repository:transfer:start` | Started repository transfer | +| `repository:transfer:finish` | Transferred repository to new owner | +| `repository:transfer:cancel` | Canceled repository transfer | +| `repository:wiki:delete` | Deleted wiki of repository | +| `repository:collaborator:add` | Added user as collaborator for repository | +| `repository:collaborator:access` | Changed access mode of collaborator | +| `repository:collaborator:remove` | Removed user as collaborator of repository | +| `repository:collaborator:team:add` | Added team as collaborator for repository | +| `repository:collaborator:team:remove` | Removed team as collaborator of repository | +| `repository:branch:default` | Changed default branch | +| `repository:branch:protection:add` | Added branch protection | +| `repository:branch:protection:update` | Updated branch protection | +| `repository:branch:protection:remove` | Removed branch protection | +| `repository:tag:protection:add` | Added tag protection | +| `repository:tag:protection:update` | Updated tag protection | +| `repository:tag:protection:remove` | Removed tag protection | +| `repository:webhook:add` | Added webhook | +| `repository:webhook:update` | Updated webhook | +| `repository:webhook:remove` | Removed webhook | +| `repository:deploykey:add` | Added deploy key | +| `repository:deploykey:remove` | Removed deploy key | +| `repository:secret:add` | Added secret | +| `repository:secret:update` | Updated secret | +| `repository:secret:remove` | Removed secret | + +### System Events + +| Event | Description | +| - | - | +| `system:startup` | System startup | +| `system:shutdown` | Normal system shutdown (unexpected shutdowns may not be logged) | +| `system:webhook:add` | Added webhook | +| `system:webhook:update` | Updated webhook | +| `system:webhook:remove` | Removed webhook | +| `system:authenticationsource:add` | Created authentication source | +| `system:authenticationsource:update` | Updated authentication source | +| `system:authenticationsource:remove` | Removed authentication source | +| `system:oauth2application:add` | Created OAuth2 application | +| `system:oauth2application:update` | Updated OAuth2 application | +| `system:oauth2application:secret` | Regenerated secret for OAuth2 application | +| `system:oauth2application:remove` | Removed OAuth2 application | diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index 7a18732c327a9..ff0de00ba4d74 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -286,17 +286,17 @@ func PublicKeyIsExternallyManaged(ctx context.Context, id int64) (bool, error) { return false, nil } -// deleteKeysMarkedForDeletion returns true if ssh keys needs update -func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, error) { +// deleteKeysMarkedForDeletion returns the deleted keys +func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) ([]*PublicKey, error) { // Start session ctx, committer, err := db.TxContext(ctx) if err != nil { - return false, err + return nil, err } defer committer.Close() - // Delete keys marked for deletion - var sshKeysNeedUpdate bool + deletedKeys := make([]*PublicKey, 0, len(keys)) + for _, KeyToDelete := range keys { key, err := SearchPublicKeyByContent(ctx, KeyToDelete) if err != nil { @@ -307,19 +307,21 @@ func deleteKeysMarkedForDeletion(ctx context.Context, keys []string) (bool, erro log.Error("DeleteByID[PublicKey]: %v", err) continue } - sshKeysNeedUpdate = true + + deletedKeys = append(deletedKeys, key) } if err := committer.Commit(); err != nil { - return false, err + return nil, err } - return sshKeysNeedUpdate, nil + return deletedKeys, nil } -// AddPublicKeysBySource add a users public keys. Returns true if there are changes. -func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { - var sshKeysNeedUpdate bool +// AddPublicKeysBySource add a users public keys. Returns the added keys. +func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) []*PublicKey { + addedKeys := make([]*PublicKey, 0, len(sshPublicKeys)) + for _, sshKey := range sshPublicKeys { var err error found := false @@ -337,28 +339,27 @@ func AddPublicKeysBySource(ctx context.Context, usr *user_model.User, s *auth.So marshalled = marshalled[:len(marshalled)-1] sshKeyName := fmt.Sprintf("%s-%s", s.Name, ssh.FingerprintSHA256(out)) - if _, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil { + if pubKey, err := AddPublicKey(ctx, usr.ID, sshKeyName, marshalled, s.ID); err != nil { if IsErrKeyAlreadyExist(err) { log.Trace("AddPublicKeysBySource[%s]: Public SSH Key %s already exists for user", sshKeyName, usr.Name) } else { log.Error("AddPublicKeysBySource[%s]: Error adding Public SSH Key for user %s: %v", sshKeyName, usr.Name, err) } } else { + addedKeys = append(addedKeys, pubKey) + log.Trace("AddPublicKeysBySource[%s]: Added Public SSH Key for user %s", sshKeyName, usr.Name) - sshKeysNeedUpdate = true } } if !found && err != nil { log.Warn("AddPublicKeysBySource[%s]: Skipping invalid Public SSH Key for user %s: %v", s.Name, usr.Name, sshKey) } } - return sshKeysNeedUpdate + return addedKeys } -// SynchronizePublicKeys updates a users public keys. Returns true if there are changes. -func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) bool { - var sshKeysNeedUpdate bool - +// SynchronizePublicKeys updates a users public keys. Returns the updated keys. +func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.Source, sshPublicKeys []string) (addedKeys, deletedKeys []*PublicKey) { log.Trace("synchronizePublicKeys[%s]: Handling Public SSH Key synchronization for user %s", s.Name, usr.Name) // Get Public Keys from DB with current LDAP source @@ -390,7 +391,7 @@ func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.So // Check if Public Key sync is needed if util.SliceSortedEqual(giteaKeys, providedKeys) { log.Trace("synchronizePublicKeys[%s]: Public Keys are already in sync for %s (Source:%v/DB:%v)", s.Name, usr.Name, len(providedKeys), len(giteaKeys)) - return false + return nil, nil } log.Trace("synchronizePublicKeys[%s]: Public Key needs update for user %s (Source:%v/DB:%v)", s.Name, usr.Name, len(providedKeys), len(giteaKeys)) @@ -401,9 +402,8 @@ func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.So newKeys = append(newKeys, key) } } - if AddPublicKeysBySource(ctx, usr, s, newKeys) { - sshKeysNeedUpdate = true - } + + addedKeys = AddPublicKeysBySource(ctx, usr, s, newKeys) // Mark keys from DB that no longer exist in the source for deletion var giteaKeysToDelete []string @@ -415,13 +415,10 @@ func SynchronizePublicKeys(ctx context.Context, usr *user_model.User, s *auth.So } // Delete keys from DB that no longer exist in the source - needUpd, err := deleteKeysMarkedForDeletion(ctx, giteaKeysToDelete) + deletedKeys, err = deleteKeysMarkedForDeletion(ctx, giteaKeysToDelete) if err != nil { log.Error("synchronizePublicKeys[%s]: Error deleting Public Keys marked for deletion for user %s: %v", s.Name, usr.Name, err) } - if needUpd { - sshKeysNeedUpdate = true - } - return sshKeysNeedUpdate + return addedKeys, deletedKeys } diff --git a/models/audit/action.go b/models/audit/action.go new file mode 100644 index 0000000000000..d92130f158df5 --- /dev/null +++ b/models/audit/action.go @@ -0,0 +1,125 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +type Action string + +const ( + UserImpersonation Action = "user:impersonation" + UserCreate Action = "user:create" + UserDelete Action = "user:delete" + UserAuthenticationFailTwoFactor Action = "user:authentication:fail:twofactor" + UserAuthenticationSource Action = "user:authentication:source" + UserActive Action = "user:active" + UserRestricted Action = "user:restricted" + UserAdmin Action = "user:admin" + UserName Action = "user:name" + UserPassword Action = "user:password" + UserPasswordResetRequest Action = "user:password:resetrequest" + UserVisibility Action = "user:visibility" + UserEmailPrimaryChange Action = "user:email:primary" + UserEmailAdd Action = "user:email:add" + UserEmailActivate Action = "user:email:activate" + UserEmailRemove Action = "user:email:remove" + UserTwoFactorEnable Action = "user:twofactor:enable" + UserTwoFactorRegenerate Action = "user:twofactor:regenerate" + UserTwoFactorDisable Action = "user:twofactor:disable" + UserWebAuthAdd Action = "user:webauth:add" + UserWebAuthRemove Action = "user:webauth:remove" + UserExternalLoginAdd Action = "user:externallogin:add" + UserExternalLoginRemove Action = "user:externallogin:remove" + UserOpenIDAdd Action = "user:openid:add" + UserOpenIDRemove Action = "user:openid:remove" + UserAccessTokenAdd Action = "user:accesstoken:add" + UserAccessTokenRemove Action = "user:accesstoken:remove" + UserOAuth2ApplicationAdd Action = "user:oauth2application:add" + UserOAuth2ApplicationUpdate Action = "user:oauth2application:update" + UserOAuth2ApplicationSecret Action = "user:oauth2application:secret" + UserOAuth2ApplicationGrant Action = "user:oauth2application:grant" + UserOAuth2ApplicationRevoke Action = "user:oauth2application:revoke" + UserOAuth2ApplicationRemove Action = "user:oauth2application:remove" + UserKeySSHAdd Action = "user:key:ssh:add" + UserKeySSHRemove Action = "user:key:ssh:remove" + UserKeyPrincipalAdd Action = "user:key:principal:add" + UserKeyPrincipalRemove Action = "user:key:principal:remove" + UserKeyGPGAdd Action = "user:key:gpg:add" + UserKeyGPGRemove Action = "user:key:gpg:remove" + UserSecretAdd Action = "user:secret:add" + UserSecretUpdate Action = "user:secret:update" + UserSecretRemove Action = "user:secret:remove" + UserWebhookAdd Action = "user:webhook:add" + UserWebhookUpdate Action = "user:webhook:update" + UserWebhookRemove Action = "user:webhook:remove" + + OrganizationCreate Action = "organization:create" + OrganizationDelete Action = "organization:delete" + OrganizationName Action = "organization:name" + OrganizationVisibility Action = "organization:visibility" + OrganizationTeamAdd Action = "organization:team:add" + OrganizationTeamUpdate Action = "organization:team:update" + OrganizationTeamRemove Action = "organization:team:remove" + OrganizationTeamPermission Action = "organization:team:permission" + OrganizationTeamMemberAdd Action = "organization:team:member:add" + OrganizationTeamMemberRemove Action = "organization:team:member:remove" + OrganizationOAuth2ApplicationAdd Action = "organization:oauth2application:add" + OrganizationOAuth2ApplicationUpdate Action = "organization:oauth2application:update" + OrganizationOAuth2ApplicationSecret Action = "organization:oauth2application:secret" + OrganizationOAuth2ApplicationRemove Action = "organization:oauth2application:remove" + OrganizationSecretAdd Action = "organization:secret:add" + OrganizationSecretUpdate Action = "organization:secret:update" + OrganizationSecretRemove Action = "organization:secret:remove" + OrganizationWebhookAdd Action = "organization:webhook:add" + OrganizationWebhookUpdate Action = "organization:webhook:update" + OrganizationWebhookRemove Action = "organization:webhook:remove" + + RepositoryCreate Action = "repository:create" + RepositoryCreateFork Action = "repository:create:fork" + RepositoryArchive Action = "repository:archive" + RepositoryUnarchive Action = "repository:unarchive" + RepositoryDelete Action = "repository:delete" + RepositoryName Action = "repository:name" + RepositoryVisibility Action = "repository:visibility" + RepositoryConvertFork Action = "repository:convert:fork" + RepositoryConvertMirror Action = "repository:convert:mirror" + RepositoryMirrorPushAdd Action = "repository:mirror:push:add" + RepositoryMirrorPushRemove Action = "repository:mirror:push:remove" + RepositorySigningVerification Action = "repository:signingverification" + RepositoryTransferStart Action = "repository:transfer:start" + RepositoryTransferFinish Action = "repository:transfer:finish" + RepositoryTransferCancel Action = "repository:transfer:cancel" + RepositoryWikiDelete Action = "repository:wiki:delete" + RepositoryCollaboratorAdd Action = "repository:collaborator:add" + RepositoryCollaboratorAccess Action = "repository:collaborator:access" + RepositoryCollaboratorRemove Action = "repository:collaborator:remove" + RepositoryCollaboratorTeamAdd Action = "repository:collaborator:team:add" + RepositoryCollaboratorTeamRemove Action = "repository:collaborator:team:remove" + RepositoryBranchDefault Action = "repository:branch:default" + RepositoryBranchProtectionAdd Action = "repository:branch:protection:add" + RepositoryBranchProtectionUpdate Action = "repository:branch:protection:update" + RepositoryBranchProtectionRemove Action = "repository:branch:protection:remove" + RepositoryTagProtectionAdd Action = "repository:tag:protection:add" + RepositoryTagProtectionUpdate Action = "repository:tag:protection:update" + RepositoryTagProtectionRemove Action = "repository:tag:protection:remove" + RepositoryWebhookAdd Action = "repository:webhook:add" + RepositoryWebhookUpdate Action = "repository:webhook:update" + RepositoryWebhookRemove Action = "repository:webhook:remove" + RepositoryDeployKeyAdd Action = "repository:deploykey:add" + RepositoryDeployKeyRemove Action = "repository:deploykey:remove" + RepositorySecretAdd Action = "repository:secret:add" + RepositorySecretUpdate Action = "repository:secret:update" + RepositorySecretRemove Action = "repository:secret:remove" + + SystemStartup Action = "system:startup" + SystemShutdown Action = "system:shutdown" + SystemWebhookAdd Action = "system:webhook:add" + SystemWebhookUpdate Action = "system:webhook:update" + SystemWebhookRemove Action = "system:webhook:remove" + SystemAuthenticationSourceAdd Action = "system:authenticationsource:add" + SystemAuthenticationSourceUpdate Action = "system:authenticationsource:update" + SystemAuthenticationSourceRemove Action = "system:authenticationsource:remove" + SystemOAuth2ApplicationAdd Action = "system:oauth2application:add" + SystemOAuth2ApplicationUpdate Action = "system:oauth2application:update" + SystemOAuth2ApplicationSecret Action = "system:oauth2application:secret" + SystemOAuth2ApplicationRemove Action = "system:oauth2application:remove" +) diff --git a/models/audit/audit_event.go b/models/audit/audit_event.go new file mode 100644 index 0000000000000..8e95a051589a1 --- /dev/null +++ b/models/audit/audit_event.go @@ -0,0 +1,101 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/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"` + ScopeType ObjectType `xorm:"INDEX(scope) NOT NULL"` + ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` + TargetType ObjectType `xorm:"NOT NULL"` + TargetID int64 `xorm:"NOT NULL"` + Message string + IPAddress string + TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"` +} + +func (*Event) TableName() string { + return "audit_event" +} + +func InsertEvent(ctx context.Context, e *Event) (*Event, error) { + return e, db.Insert(ctx, e) +} + +type EventSort = string + +const ( + SortTimestampAsc EventSort = "timestamp_asc" + SortTimestampDesc EventSort = "timestamp_desc" +) + +type EventSearchOptions struct { + Action Action + ActorID int64 + ScopeType ObjectType + ScopeID int64 + Sort EventSort + db.Paginator +} + +func (opts *EventSearchOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.Action != "" { + cond = cond.And(builder.Eq{"action": opts.Action}) + } + if opts.ActorID != 0 { + cond = cond.And(builder.Eq{"actor_id": opts.ActorID}) + } + if opts.ScopeID != 0 && opts.ScopeType != "" { + cond = cond.And(builder.Eq{ + "audit_event.scope_type": opts.ScopeType, + "audit_event.scope_id": opts.ScopeID, + }) + } + + return cond +} + +func (opts *EventSearchOptions) configureOrderBy(e db.Engine) { + switch opts.Sort { + case SortTimestampAsc: + e.Asc("timestamp_unix") + default: + e.Desc("timestamp_unix") + } + + // Sort by id for stable order with duplicates in the other field + e.Asc("id") +} + +func FindEvents(ctx context.Context, opts *EventSearchOptions) ([]*Event, int64, error) { + sess := db.GetEngine(ctx). + Where(opts.ToConds()). + Table("audit_event") + + opts.configureOrderBy(sess) + + if opts.Paginator != nil { + sess = db.SetSessionPagination(sess, opts) + } + + evs := make([]*Event, 0, 10) + count, err := sess.FindAndCount(&evs) + return evs, count, err +} diff --git a/models/audit/types.go b/models/audit/types.go new file mode 100644 index 0000000000000..f6e098e8fe5e3 --- /dev/null +++ b/models/audit/types.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +type ObjectType string + +const ( + TypeSystem ObjectType = "system" + TypeRepository ObjectType = "repository" + TypeUser ObjectType = "user" + TypeOrganization ObjectType = "organization" + TypeEmailAddress ObjectType = "email_address" + TypeTeam ObjectType = "team" + TypeTwoFactor ObjectType = "twofactor" + TypeWebAuthnCredential ObjectType = "webauthn" + TypeOpenID ObjectType = "openid" + TypeAccessToken ObjectType = "access_token" + TypeOAuth2Application ObjectType = "oauth2_application" + TypeOAuth2Grant ObjectType = "oauth2_grant" + TypeAuthenticationSource ObjectType = "authentication_source" + TypePublicKey ObjectType = "public_key" + TypeGPGKey ObjectType = "gpg_key" + TypeSecret ObjectType = "secret" + TypeWebhook ObjectType = "webhook" + TypeProtectedTag ObjectType = "protected_tag" + TypeProtectedBranch ObjectType = "protected_branch" + TypePushMirror ObjectType = "push_mirror" + TypeRepoTransfer ObjectType = "repo_transfer" +) diff --git a/models/auth/access_token.go b/models/auth/access_token.go index 63331b4841256..3db89d0e42634 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -222,6 +222,19 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error { return err } +func GetAccessTokenByID(ctx context.Context, id, userID int64) (*AccessToken, error) { + t := &AccessToken{ + UID: userID, + } + has, err := db.GetEngine(ctx).ID(id).Get(t) + if err != nil { + return nil, err + } else if !has { + return nil, ErrAccessTokenNotExist{} + } + 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{ diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4c3cefde7bf70..a6af2d4d47531 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -368,6 +368,7 @@ func prepareMigrationTasks() []*migration { newMigration(308, "Add index(user_id, is_deleted) for action table", v1_23.AddNewIndexForUserDashboard), newMigration(309, "Improve Notification table indices", v1_23.ImproveNotificationTableIndices), newMigration(310, "Add Priority to ProtectedBranch", v1_23.AddPriorityToProtectedBranch), + newMigration(311, "Add audit_event table", v1_23.AddAuditEventTable), } return preparedMigrations } diff --git a/models/migrations/v1_23/v311.go b/models/migrations/v1_23/v311.go new file mode 100644 index 0000000000000..9f0ded8807961 --- /dev/null +++ b/models/migrations/v1_23/v311.go @@ -0,0 +1,27 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func AddAuditEventTable(x *xorm.Engine) error { + type AuditEvent struct { + ID int64 `xorm:"pk autoincr"` + Action string `xorm:"INDEX NOT NULL"` + ActorID int64 `xorm:"INDEX NOT NULL"` + ScopeType string `xorm:"INDEX(scope) NOT NULL"` + ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` + TargetType string `xorm:"NOT NULL"` + TargetID int64 `xorm:"NOT NULL"` + Message string + IPAddress string + TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"` + } + + return x.Sync(&AuditEvent{}) +} diff --git a/models/organization/org.go b/models/organization/org.go index 725a99356e974..91826c28e899c 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -85,6 +85,11 @@ func OrgFromUser(user *user_model.User) *Organization { return (*Organization)(user) } +// UserFromOrg converts organization to user +func UserFromOrg(org *Organization) *user_model.User { + return (*user_model.User)(org) +} + // TableName represents the real table name of Organization func (Organization) TableName() string { return "user" diff --git a/models/user/email_address.go b/models/user/email_address.go index 5c04909ed7c59..b0a19009b37fb 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -353,7 +353,7 @@ func ChangeInactivePrimaryEmail(ctx context.Context, uid int64, oldEmailAddr, ne } // VerifyActiveEmailCode verifies active email code when active account -func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress { +func VerifyActiveEmailCode(ctx context.Context, code, email string) (*User, *EmailAddress) { if user := GetVerifyUser(ctx, code); user != nil { // time limit code prefix := code[:base.TimeLimitCodeLength] @@ -362,11 +362,11 @@ func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddres if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { emailAddress := &EmailAddress{UID: user.ID, Email: email} if has, _ := db.GetEngine(ctx).Get(emailAddress); has { - return emailAddress + return user, emailAddress } } } - return nil + return nil, nil } // SearchEmailOrderBy is used to sort the results from SearchEmails() @@ -453,10 +453,10 @@ func SearchEmails(ctx context.Context, opts *SearchEmailOptions) ([]*SearchEmail // ActivateUserEmail will change the activated state of an email address, // either primary or secondary (all in the email_address table) -func ActivateUserEmail(ctx context.Context, userID int64, email string, activate bool) (err error) { +func ActivateUserEmail(ctx context.Context, userID int64, email string, activate bool) (*EmailAddress, error) { ctx, committer, err := db.TxContext(ctx) if err != nil { - return err + return nil, err } defer committer.Close() @@ -464,48 +464,48 @@ func ActivateUserEmail(ctx context.Context, userID int64, email string, activate // First check if there's another user active with the same address addr, exist, err := db.Get[EmailAddress](ctx, builder.Eq{"uid": userID, "lower_email": strings.ToLower(email)}) if err != nil { - return err + return nil, err } else if !exist { - return fmt.Errorf("no such email: %d (%s)", userID, email) + return nil, fmt.Errorf("no such email: %d (%s)", userID, email) } if addr.IsActivated == activate { // Already in the desired state; no action - return nil + return addr, nil } if activate { if used, err := IsEmailActive(ctx, email, addr.ID); err != nil { - return fmt.Errorf("unable to check isEmailActive() for %s: %w", email, err) + return nil, fmt.Errorf("unable to check isEmailActive() for %s: %w", email, err) } else if used { - return ErrEmailAlreadyUsed{Email: email} + return nil, ErrEmailAlreadyUsed{Email: email} } } if err = updateActivation(ctx, addr, activate); err != nil { - return fmt.Errorf("unable to updateActivation() for %d:%s: %w", addr.ID, addr.Email, err) + return nil, fmt.Errorf("unable to updateActivation() for %d:%s: %w", addr.ID, addr.Email, err) } // Activate/deactivate a user's primary email address and account if addr.IsPrimary { user, exist, err := db.Get[User](ctx, builder.Eq{"id": userID, "email": email}) if err != nil { - return err + return nil, err } else if !exist { - return fmt.Errorf("no user with ID: %d and Email: %s", userID, email) + return nil, fmt.Errorf("no user with ID: %d and Email: %s", userID, email) } // The user's activation state should be synchronized with the primary email if user.IsActive != activate { user.IsActive = activate if user.Rands, err = GetUserSalt(); err != nil { - return fmt.Errorf("unable to generate salt: %w", err) + return nil, fmt.Errorf("unable to generate salt: %w", err) } if err = UpdateUserCols(ctx, user, "is_active", "rands"); err != nil { - return fmt.Errorf("unable to updateUserCols() for user ID: %d: %w", userID, err) + return nil, fmt.Errorf("unable to updateUserCols() for user ID: %d: %w", userID, err) } } } - return committer.Commit() + return addr, committer.Commit() } // validateEmailBasic checks whether the email complies with the rules diff --git a/models/user/openid.go b/models/user/openid.go index ee4ecabae0b76..e084e71b02702 100644 --- a/models/user/openid.go +++ b/models/user/openid.go @@ -9,6 +9,8 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" ) // ErrOpenIDNotExist openid is not known @@ -40,6 +42,21 @@ func GetUserOpenIDs(ctx context.Context, uid int64) ([]*UserOpenID, error) { return openids, nil } +func GetUserOpenID(ctx context.Context, id, uid int64) (*UserOpenID, error) { + openid, has, err := db.Get[UserOpenID](ctx, builder.Eq{ + "id": id, + "uid": uid, + }) + + if err != nil { + return nil, err + } else if !has { + return nil, util.ErrNotExist + } + + return openid, nil +} + // isOpenIDUsed returns true if the openid has been used. func isOpenIDUsed(ctx context.Context, uri string) (bool, error) { if len(uri) == 0 { diff --git a/models/user/user_system.go b/models/user/user_system.go index 612cdb2caef26..a50bad05ceddb 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -68,3 +68,19 @@ func NewActionsUser() *User { func (u *User) IsActions() bool { return u != nil && u.ID == ActionsUserID } + +func NewCLIUser() *User { + return &User{ + ID: -3, + Name: "CLI", + LowerName: "cli", + } +} + +func NewAuthenticationSourceUser() *User { + return &User{ + ID: -4, + Name: "AuthenticationSource", + LowerName: "authenticationsource", + } +} diff --git a/modules/httplib/request.go b/modules/httplib/request.go index 880d7ad3cbe36..423b5e3076a81 100644 --- a/modules/httplib/request.go +++ b/modules/httplib/request.go @@ -204,3 +204,14 @@ func TimeoutDialer(cTimeout time.Duration) func(ctx context.Context, net, addr s func (r *Request) GoString() string { return fmt.Sprintf("%s %s", r.req.Method, r.url) } + +func TryGetIPAddress(ctx context.Context) string { + if req, _ := ctx.Value(RequestContextKey).(*http.Request); req != nil { + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return req.RemoteAddr + } + return host + } + return "" +} diff --git a/modules/setting/audit.go b/modules/setting/audit.go new file mode 100644 index 0000000000000..13de905ae13a4 --- /dev/null +++ b/modules/setting/audit.go @@ -0,0 +1,61 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "compress/gzip" + "os" + "path" + "path/filepath" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" +) + +var Audit = struct { + Enabled bool + FileOptions *log.WriterFileOption `ini:"-"` +}{ + Enabled: false, +} + +func loadAuditFrom(rootCfg ConfigProvider) { + mustMapSetting(rootCfg, "audit", &Audit) + + sec, err := rootCfg.GetSection("audit.file") + if err == nil { + if !ConfigSectionKeyBool(sec, "ENABLED") { + return + } + + opts := &log.WriterFileOption{ + FileName: path.Join(Log.RootPath, "audit.log"), + LogRotate: true, + DailyRotate: true, + MaxDays: 7, + Compress: true, + CompressionLevel: gzip.DefaultCompression, + } + + if err := sec.MapTo(opts); err != nil { + log.Fatal("Failed to map audit file settings: %v", err) + } + + opts.FileName = util.FilePathJoinAbs(opts.FileName) + if !filepath.IsAbs(opts.FileName) { + opts.FileName = path.Join(Log.RootPath, opts.FileName) + } + + if err := os.MkdirAll(filepath.Dir(opts.FileName), os.ModePerm); err != nil { + log.Fatal("Unable to create directory for audit log %s: %v", opts.FileName, err) + } + + opts.MaxSize = mustBytes(sec, "MAXIMUM_SIZE") + if opts.MaxSize <= 0 { + opts.MaxSize = 1 << 28 + } + + Audit.FileOptions = opts + } +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index c93d199b1b639..ad25774745695 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -149,6 +149,8 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadMarkupFrom(cfg) loadGlobalLockFrom(cfg) loadOtherFrom(cfg) + loadQueueFrom(cfg) + loadAuditFrom(cfg) return nil } @@ -213,7 +215,7 @@ func LoadSettings() { loadMigrationsFrom(CfgProvider) loadIndexerFrom(CfgProvider) loadTaskFrom(CfgProvider) - LoadQueueSettings() + loadQueueFrom(CfgProvider) loadProjectFrom(CfgProvider) loadMimeTypeMapFrom(CfgProvider) loadFederationFrom(CfgProvider) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8da7412adeb35..53d2949ef4996 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3332,6 +3332,17 @@ config.xorm_log_sql = Log SQL config.set_setting_failed = Set setting %s failed +monitor.audit.title = Audit Logs +monitor.audit.actor = Actor +monitor.audit.scope = Scope +monitor.audit.target = Target +monitor.audit.action = Action +monitor.audit.ip_address = IP Address +monitor.audit.timestamp = Timestamp +monitor.audit.no_events = There are no audit events matching the filter. +monitor.audit.deleted.actor = (removed) +monitor.audit.deleted.type = (removed %s [%v]) + monitor.stats = Stats monitor.cron = Cron Tasks diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go index a5c299bbf01c8..1962bf5f8612a 100644 --- a/routers/api/v1/admin/org.go +++ b/routers/api/v1/admin/org.go @@ -13,6 +13,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -74,6 +75,8 @@ func CreateOrg(ctx *context.APIContext) { return } + audit.RecordUserCreate(ctx, ctx.Doer, 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 b0f40084da38b..0edb5f9279d9a 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/mailer" @@ -152,6 +153,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.RecordUserCreate(ctx, ctx.Doer, u) + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) // Send email notification. @@ -199,7 +202,7 @@ func EditUser(ctx *context.APIContext) { MustChangePassword: optional.FromPtr(form.MustChangePassword), ProhibitLogin: optional.FromPtr(form.ProhibitLogin), } - if err := user_service.UpdateAuth(ctx, ctx.ContextUser, authOpts); err != nil { + if err := user_service.UpdateAuth(ctx, ctx.Doer, ctx.ContextUser, authOpts); err != nil { switch { case errors.Is(err, password.ErrMinLength): ctx.Error(http.StatusBadRequest, "PasswordTooShort", fmt.Errorf("password must be at least %d characters", setting.MinPasswordLength)) @@ -214,7 +217,7 @@ func EditUser(ctx *context.APIContext) { } if form.Email != nil { - if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.Doer, ctx.ContextUser, *form.Email); err != nil { switch { case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): ctx.Error(http.StatusBadRequest, "EmailInvalid", err) @@ -246,17 +249,15 @@ func EditUser(ctx *context.APIContext) { IsRestricted: optional.FromPtr(form.Restricted), } - if err := user_service.UpdateUser(ctx, ctx.ContextUser, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, ctx.ContextUser, opts); err != nil { if models.IsErrDeleteLastAdminUser(err) { ctx.Error(http.StatusBadRequest, "LastAdmin", err) } else { - ctx.Error(http.StatusInternalServerError, "UpdateUser", err) + ctx.Error(http.StatusInternalServerError, "UpdateOrSetPrimaryEmail", err) } return } - log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, ctx.ContextUser.Name) - ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer)) } @@ -298,7 +299,7 @@ func DeleteUser(ctx *context.APIContext) { return } - if err := user_service.DeleteUser(ctx, ctx.ContextUser, ctx.FormBool("purge")); err != nil { + if err := user_service.DeleteUser(ctx, ctx.Doer, ctx.ContextUser, ctx.FormBool("purge")); err != nil { if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || models.IsErrUserOwnPackages(err) || @@ -479,7 +480,7 @@ func RenameUser(ctx *context.APIContext) { newName := web.GetForm(ctx).(*api.RenameUserOption).NewName // Check if user name has been changed - if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil { + if err := user_service.RenameUser(ctx, ctx.Doer, ctx.ContextUser, newName); err != nil { switch { case user_model.IsErrUserAlreadyExist(err): ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken")) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f28ee980e1043..11f80f5589c63 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -94,6 +94,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -122,6 +123,9 @@ func sudo() func(ctx *context.APIContext) { } return } + + audit.RecordUserImpersonation(ctx, ctx.Doer, user) + log.Trace("Sudo from (%s) to: %s", ctx.Doer.Name, user.Name) ctx.Doer = user } else { diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 199ee7d7773b9..645cf91baa84d 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -106,7 +106,7 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) { opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) - _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data) + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer, ctx.Org.Organization.AsUser(), nil, ctx.PathParam("secretname"), opt.Data) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) @@ -153,7 +153,7 @@ func (Action) DeleteSecret(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname")) + err := secret_service.DeleteSecretByName(ctx, ctx.Doer, ctx.Org.Organization.AsUser(), nil, ctx.PathParam("secretname")) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.Error(http.StatusBadRequest, "DeleteSecret", err) diff --git a/routers/api/v1/org/hook.go b/routers/api/v1/org/hook.go index df82f4e5a2b38..d8e57d481bca4 100644 --- a/routers/api/v1/org/hook.go +++ b/routers/api/v1/org/hook.go @@ -13,7 +13,7 @@ import ( webhook_service "code.gitea.io/gitea/services/webhook" ) -// ListHooks list an organziation's webhooks +// ListHooks list an organization's webhooks func ListHooks(ctx *context.APIContext) { // swagger:operation GET /orgs/{org}/hooks organization orgListHooks // --- diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index 3fb653bcb6d0c..6866585850022 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" feed_service "code.gitea.io/gitea/services/feed" @@ -278,6 +279,8 @@ func Create(ctx *context.APIContext) { return } + audit.RecordUserCreate(ctx, ctx.Doer, org.AsUser()) + ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org)) } @@ -343,8 +346,10 @@ func Edit(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.EditOrgOption) + org := ctx.Org.Organization + if form.Email != "" { - if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Org.Organization.AsUser(), form.Email); err != nil { + if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Doer, org.AsUser(), form.Email); err != nil { ctx.Error(http.StatusInternalServerError, "ReplacePrimaryEmailAddress", err) return } @@ -358,12 +363,12 @@ func Edit(ctx *context.APIContext) { Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess), } - if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, org.AsUser(), opts); err != nil { ctx.Error(http.StatusInternalServerError, "UpdateUser", err) return } - ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, ctx.Org.Organization)) + ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, org)) } // Delete an organization @@ -385,7 +390,7 @@ func Delete(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - if err := org.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { + if err := org.DeleteOrganization(ctx, ctx.Doer, ctx.Org.Organization, false); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteOrganization", err) return } diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index bc50960b61b11..0d4a120c281a0 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/user" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" feed_service "code.gitea.io/gitea/services/feed" @@ -249,6 +250,8 @@ func CreateTeam(ctx *context.APIContext) { return } + audit.RecordOrganizationTeamAdd(ctx, ctx.Doer, ctx.Org.Organization, team) + apiTeam, err := convert.ToTeam(ctx, team, true) if err != nil { ctx.InternalServerError(err) @@ -284,6 +287,13 @@ func EditTeam(ctx *context.APIContext) { form := web.GetForm(ctx).(*api.EditTeamOption) team := ctx.Org.Team + + org, err := organization.GetOrgByID(ctx, team.OrgID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetOrgByID", err) + return + } + if err := team.LoadUnits(ctx); err != nil { ctx.InternalServerError(err) return @@ -336,6 +346,11 @@ func EditTeam(ctx *context.APIContext) { return } + audit.RecordOrganizationTeamUpdate(ctx, ctx.Doer, org, team) + if isAuthChanged { + audit.RecordOrganizationTeamPermission(ctx, ctx.Doer, org, team) + } + apiTeam, err := convert.ToTeam(ctx, team) if err != nil { ctx.InternalServerError(err) @@ -362,10 +377,19 @@ func DeleteTeam(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" + org, err := organization.GetOrgByID(ctx, ctx.Org.Team.OrgID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetOrgByID", err) + return + } + if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteTeam", err) return } + + audit.RecordOrganizationTeamRemove(ctx, ctx.Doer, org, ctx.Org.Team) + ctx.Status(http.StatusNoContent) } @@ -504,6 +528,15 @@ func AddTeamMember(ctx *context.APIContext) { } return } + + org, err := organization.GetOrgByID(ctx, ctx.Org.Team.OrgID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetOrgByID", err) + return + } + + audit.RecordOrganizationTeamMemberAdd(ctx, ctx.Doer, org, ctx.Org.Team, u) + ctx.Status(http.StatusNoContent) } @@ -541,6 +574,15 @@ func RemoveTeamMember(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "RemoveTeamMember", err) return } + + org, err := organization.GetOrgByID(ctx, ctx.Org.Team.OrgID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetOrgByID", err) + return + } + + audit.RecordOrganizationTeamMemberRemove(ctx, ctx.Doer, org, ctx.Org.Team, u) + ctx.Status(http.StatusNoContent) } @@ -700,7 +742,7 @@ func AddTeamRepository(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") return } - if err := repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil { + if err := repo_service.TeamAddRepository(ctx, ctx.Doer, ctx.Org.Team, repo); err != nil { ctx.Error(http.StatusInternalServerError, "TeamAddRepository", err) return } @@ -752,10 +794,11 @@ func RemoveTeamRepository(ctx *context.APIContext) { ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") return } - if err := repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, repo.ID); err != nil { + if err := repo_service.RemoveRepositoryFromTeam(ctx, ctx.Doer, ctx.Org.Team, repo); err != nil { ctx.Error(http.StatusInternalServerError, "RemoveRepository", err) return } + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index d27e8d2427b39..5aad8532009bb 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -117,11 +117,9 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - repo := ctx.Repo.Repository - opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) - _, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.PathParam("secretname"), opt.Data) + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer, ctx.Repo.Owner, ctx.Repo.Repository, ctx.PathParam("secretname"), opt.Data) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) @@ -173,9 +171,7 @@ func (Action) DeleteSecret(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - repo := ctx.Repo.Repository - - err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.PathParam("secretname")) + err := secret_service.DeleteSecretByName(ctx, ctx.Doer, ctx.Repo.Owner, ctx.Repo.Repository, ctx.PathParam("secretname")) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.Error(http.StatusBadRequest, "DeleteSecret", err) diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index 0bbf5a1ea49e8..010eacdc9c18e 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -183,7 +183,7 @@ func AddOrUpdateCollaborator(ctx *context.APIContext) { p = perm.ParseAccessMode(*form.Permission) } - if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, collaborator, p); err != nil { + if err := repo_service.AddOrUpdateCollaborator(ctx, ctx.Doer, ctx.Repo.Repository, collaborator, p); err != nil { if errors.Is(err, user_model.ErrBlockedUser) { ctx.Error(http.StatusForbidden, "AddOrUpdateCollaborator", err) } else { @@ -236,10 +236,11 @@ func DeleteCollaborator(ctx *context.APIContext) { return } - if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil { + if err := repo_service.DeleteCollaboration(ctx, ctx.Doer, ctx.Repo.Repository, collaborator); err != nil { ctx.Error(http.StatusInternalServerError, "DeleteCollaboration", err) return } + ctx.Status(http.StatusNoContent) } diff --git a/routers/api/v1/repo/main_test.go b/routers/api/v1/repo/main_test.go index 451f34d72fff7..c44f33b8321ec 100644 --- a/routers/api/v1/repo/main_test.go +++ b/routers/api/v1/repo/main_test.go @@ -7,15 +7,11 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/setting" webhook_service "code.gitea.io/gitea/services/webhook" ) func TestMain(m *testing.M) { unittest.MainTest(m, &unittest.TestOptions{ - SetUp: func() error { - setting.LoadQueueSettings() - return webhook_service.Init() - }, + SetUp: webhook_service.Init, }) } diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 40990a28cbdee..0bdd69201551e 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -32,6 +32,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" feed_service "code.gitea.io/gitea/services/feed" @@ -752,6 +753,10 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err return err } + if visibilityChanged { + audit.RecordRepositoryVisibility(ctx, ctx.Doer, repo) + } + if updateRepoLicense { if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{ RepoID: ctx.Repo.Repository.ID, diff --git a/routers/api/v1/repo/teams.go b/routers/api/v1/repo/teams.go index 82ecaf30201c7..c45b1b1e85d52 100644 --- a/routers/api/v1/repo/teams.go +++ b/routers/api/v1/repo/teams.go @@ -204,13 +204,13 @@ func changeRepoTeam(ctx *context.APIContext, add bool) { ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name)) return } - err = repo_service.TeamAddRepository(ctx, team, ctx.Repo.Repository) + err = repo_service.TeamAddRepository(ctx, ctx.Doer, team, ctx.Repo.Repository) } else { if !repoHasTeam { ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name)) return } - err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID) + err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Doer, team, ctx.Repo.Repository) } if err != nil { ctx.InternalServerError(err) diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index 776b336761ff8..4cab909018c0b 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" repo_service "code.gitea.io/gitea/services/repository" @@ -235,5 +236,11 @@ func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { return repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams) } - return repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository) + if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { + return err + } + + audit.RecordRepositoryTransferCancel(ctx, ctx.Doer, ctx.Repo.Repository) + + return nil } diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 22707196f4cca..c481d780e0642 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -49,7 +49,7 @@ func CreateOrUpdateSecret(ctx *context.APIContext) { opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) - _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"), opt.Data) + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer, ctx.Doer, nil, ctx.PathParam("secretname"), opt.Data) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) @@ -91,7 +91,7 @@ func DeleteSecret(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname")) + err := secret_service.DeleteSecretByName(ctx, ctx.Doer, ctx.Doer, nil, ctx.PathParam("secretname")) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.Error(http.StatusBadRequest, "DeleteSecret", err) diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index 9583bb548c7c5..2b18a32df7be8 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -14,8 +14,10 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" ) @@ -128,6 +130,9 @@ func CreateAccessToken(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "NewAccessToken", err) return } + + audit.RecordUserAccessTokenAdd(ctx, ctx.Doer, ctx.Doer, t) + ctx.JSON(http.StatusCreated, &api.AccessToken{ Name: t.Name, Token: t.Token, @@ -168,6 +173,7 @@ func DeleteAccessToken(ctx *context.APIContext) { token := ctx.PathParam(":id") tokenID, _ := strconv.ParseInt(token, 0, 64) + var t *auth_model.AccessToken if tokenID == 0 { tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{ Name: token, @@ -183,18 +189,25 @@ func DeleteAccessToken(ctx *context.APIContext) { ctx.NotFound() return case 1: - tokenID = tokens[0].ID + t = tokens[0] default: ctx.Error(http.StatusUnprocessableEntity, "DeleteAccessTokenByID", fmt.Errorf("multiple matches for token name '%s'", token)) return } + } else { + var err error + t, _, err = db.GetByID[auth_model.AccessToken](ctx, tokenID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetByID", err) + return + } } - if tokenID == 0 { - ctx.Error(http.StatusInternalServerError, "Invalid TokenID", nil) + if t == nil { + ctx.NotFound() return } - if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil { + if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.ContextUser.ID); err != nil { if auth_model.IsErrAccessTokenNotExist(err) { ctx.NotFound() } else { @@ -203,6 +216,8 @@ func DeleteAccessToken(ctx *context.APIContext) { return } + audit.RecordUserAccessTokenRemove(ctx, ctx.Doer, ctx.Doer, t) + ctx.Status(http.StatusNoContent) } @@ -245,6 +260,8 @@ func CreateOauth2Application(ctx *context.APIContext) { } app.ClientSecret = secret + audit.RecordOAuth2ApplicationAdd(ctx, ctx.Doer, ctx.Doer, app) + ctx.JSON(http.StatusCreated, convert.ToOAuth2Application(app)) } @@ -306,16 +323,28 @@ func DeleteOauth2Application(ctx *context.APIContext) { // "$ref": "#/responses/empty" // "404": // "$ref": "#/responses/notFound" - appID := ctx.PathParamInt64(":id") - if err := auth_model.DeleteOAuth2Application(ctx, appID, ctx.Doer.ID); err != nil { + + app, err := auth_model.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetOAuth2ApplicationByID", err) + } + return + } + + if err := auth_model.DeleteOAuth2Application(ctx, app.ID, ctx.Doer.ID); err != nil { if auth_model.IsErrOAuthApplicationNotFound(err) { ctx.NotFound() } else { - ctx.Error(http.StatusInternalServerError, "DeleteOauth2ApplicationByID", err) + ctx.Error(http.StatusInternalServerError, "DeleteOAuth2Application", err) } return } + audit.RecordOAuth2ApplicationRemove(ctx, ctx.Doer, ctx.Doer, app) + ctx.Status(http.StatusNoContent) } @@ -408,5 +437,7 @@ func UpdateOauth2Application(ctx *context.APIContext) { return } + audit.RecordOAuth2ApplicationUpdate(ctx, ctx.Doer, ctx.Doer, app) + ctx.JSON(http.StatusOK, convert.ToOAuth2Application(app)) } diff --git a/routers/api/v1/user/email.go b/routers/api/v1/user/email.go index 33aa851a80716..3a39632211c35 100644 --- a/routers/api/v1/user/email.go +++ b/routers/api/v1/user/email.go @@ -63,7 +63,7 @@ func AddEmail(ctx *context.APIContext) { return } - if err := user_service.AddEmailAddresses(ctx, ctx.Doer, form.Emails); err != nil { + if err := user_service.AddEmailAddresses(ctx, ctx.Doer, ctx.Doer, form.Emails); err != nil { if user_model.IsErrEmailAlreadyUsed(err) { ctx.Error(http.StatusUnprocessableEntity, "", "Email address has been used: "+err.(user_model.ErrEmailAlreadyUsed).Email) } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) { @@ -120,7 +120,7 @@ func DeleteEmail(ctx *context.APIContext) { return } - if err := user_service.DeleteEmailAddresses(ctx, ctx.Doer, form.Emails); err != nil { + if err := user_service.DeleteEmailAddresses(ctx, ctx.Doer, ctx.Doer, form.Emails); err != nil { if user_model.IsErrEmailAddressNotExist(err) { ctx.Error(http.StatusNotFound, "DeleteEmailAddresses", err) } else { diff --git a/routers/api/v1/user/settings.go b/routers/api/v1/user/settings.go index d0a8daaa85ba5..e862cb046e362 100644 --- a/routers/api/v1/user/settings.go +++ b/routers/api/v1/user/settings.go @@ -56,7 +56,7 @@ func UpdateUserSettings(ctx *context.APIContext) { KeepEmailPrivate: optional.FromPtr(form.HideEmail), KeepActivityPrivate: optional.FromPtr(form.HideActivity), } - if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, ctx.Doer, opts); err != nil { ctx.InternalServerError(err) return } diff --git a/routers/init.go b/routers/init.go index 2091f5967acac..443670f0889a5 100644 --- a/routers/init.go +++ b/routers/init.go @@ -36,6 +36,7 @@ import ( web_routers "code.gitea.io/gitea/routers/web" actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/automerge" @@ -173,6 +174,8 @@ func InitWebInstalled(ctx context.Context) { actions_service.Init() + mustInit(audit.Init) + mustInit(repo_service.InitLicenseClassifier) // Finally start up the cron diff --git a/routers/install/install.go b/routers/install/install.go index e420d36da5a1b..820d084ed01ac 100644 --- a/routers/install/install.go +++ b/routers/install/install.go @@ -34,6 +34,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/audit" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -566,6 +567,8 @@ func SubmitInstall(ctx *context.Context) { u, _ = user_model.GetUserByName(ctx, u.Name) } + audit.RecordUserCreate(ctx, u, 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 8d12b7a953663..d08855a9aebd5 100644 --- a/routers/private/hook_post_receive.go +++ b/routers/private/hook_post_receive.go @@ -25,6 +25,7 @@ import ( timeutil "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" gitea_context "code.gitea.io/gitea/services/context" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" @@ -228,6 +229,10 @@ func HookPostReceive(ctx *gitea_context.PrivateContext) { }) return } + + if isPrivate.Has() { + audit.RecordRepositoryVisibility(ctx, pusher, repo) + } } } diff --git a/routers/web/admin/applications.go b/routers/web/admin/applications.go index 9b48f21eca9f8..4d2d6d5c464f5 100644 --- a/routers/web/admin/applications.go +++ b/routers/web/admin/applications.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" user_setting "code.gitea.io/gitea/routers/web/user/setting" @@ -20,9 +21,10 @@ var ( tplSettingsOauth2ApplicationEdit base.TplName = "admin/applications/oauth2_edit" ) -func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers { +func newOAuth2CommonHandlers(doer *user_model.User) *user_setting.OAuth2CommonHandlers { return &user_setting.OAuth2CommonHandlers{ - OwnerID: 0, + Doer: doer, + Owner: nil, BasePathList: fmt.Sprintf("%s/-/admin/applications", setting.AppSubURL), BasePathEditPrefix: fmt.Sprintf("%s/-/admin/applications/oauth2", setting.AppSubURL), TplAppEdit: tplSettingsOauth2ApplicationEdit, @@ -51,7 +53,7 @@ func ApplicationsPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.applications") ctx.Data["PageIsAdminApplications"] = true - oa := newOAuth2CommonHandlers() + oa := newOAuth2CommonHandlers(ctx.Doer) oa.AddApp(ctx) } @@ -59,7 +61,7 @@ func ApplicationsPost(ctx *context.Context) { func EditApplication(ctx *context.Context) { ctx.Data["PageIsAdminApplications"] = true - oa := newOAuth2CommonHandlers() + oa := newOAuth2CommonHandlers(ctx.Doer) oa.EditShow(ctx) } @@ -68,7 +70,7 @@ func EditApplicationPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings.applications") ctx.Data["PageIsAdminApplications"] = true - oa := newOAuth2CommonHandlers() + oa := newOAuth2CommonHandlers(ctx.Doer) oa.EditSave(ctx) } @@ -77,13 +79,13 @@ func ApplicationsRegenerateSecret(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") ctx.Data["PageIsAdminApplications"] = true - oa := newOAuth2CommonHandlers() + oa := newOAuth2CommonHandlers(ctx.Doer) oa.RegenerateSecret(ctx) } // DeleteApplication deletes the given oauth2 application func DeleteApplication(ctx *context.Context) { - oa := newOAuth2CommonHandlers() + oa := newOAuth2CommonHandlers(ctx.Doer) oa.DeleteApp(ctx) } diff --git a/routers/web/admin/audit.go b/routers/web/admin/audit.go new file mode 100644 index 0000000000000..65ce54052c4fb --- /dev/null +++ b/routers/web/admin/audit.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + audit_model "code.gitea.io/gitea/models/audit" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" + "code.gitea.io/gitea/services/context" +) + +const ( + tplAuditLogs base.TplName = "admin/audit/list" +) + +func ViewAuditLogs(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.audit.title") + ctx.Data["PageIsAdminMonitorAudit"] = true + + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + opts := &audit_model.EventSearchOptions{ + Sort: ctx.FormString("sort"), + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.NoticePagingNum, + }, + } + + ctx.Data["AuditSort"] = opts.Sort + + evs, total, err := audit.FindEvents(ctx, opts) + if err != nil { + ctx.ServerError("", err) + return + } + + ctx.Data["AuditEvents"] = evs + + pager := context.NewPagination(int(total), setting.UI.Admin.NoticePagingNum, page, 5) + pager.AddParamString("sort", opts.Sort) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplAuditLogs) +} diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index 60e2b7c86fcf0..60aba22ec15d0 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/ldap" "code.gitea.io/gitea/services/auth/source/oauth2" @@ -301,13 +302,15 @@ func NewAuthSourcePost(ctx *context.Context) { return } - if err := auth.CreateSource(ctx, &auth.Source{ + source := &auth.Source{ Type: auth.Type(form.Type), Name: form.Name, IsActive: form.IsActive, IsSyncEnabled: form.IsSyncEnabled, Cfg: config, - }); err != nil { + } + + if err := auth.CreateSource(ctx, source); err != nil { if auth.IsErrSourceAlreadyExist(err) { ctx.Data["Err_Name"] = true ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthNew, form) @@ -321,6 +324,8 @@ func NewAuthSourcePost(ctx *context.Context) { return } + audit.RecordSystemAuthenticationSourceAdd(ctx, ctx.Doer, source) + log.Trace("Authentication created by admin(%s): %s", ctx.Doer.Name, form.Name) ctx.Flash.Success(ctx.Tr("admin.auths.new_success", form.Name)) @@ -434,6 +439,9 @@ func EditAuthSourcePost(ctx *context.Context) { } return } + + audit.RecordSystemAuthenticationSourceUpdate(ctx, ctx.Doer, source) + log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID) ctx.Flash.Success(ctx.Tr("admin.auths.update_success")) @@ -448,7 +456,7 @@ func DeleteAuthSource(ctx *context.Context) { return } - if err = auth_service.DeleteSource(ctx, source); err != nil { + if err = auth_service.DeleteSource(ctx, ctx.Doer, source); err != nil { if auth.IsErrSourceInUse(err) { ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used")) } else { @@ -457,6 +465,7 @@ func DeleteAuthSource(ctx *context.Context) { ctx.JSONRedirect(setting.AppSubURL + "/-/admin/auths/" + url.PathEscape(ctx.PathParam(":authid"))) return } + log.Trace("Authentication deleted by admin(%s): %d", ctx.Doer.Name, source.ID) ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success")) diff --git a/routers/web/admin/emails.go b/routers/web/admin/emails.go index e9c97d8b8f914..f0017b23e1518 100644 --- a/routers/web/admin/emails.go +++ b/routers/web/admin/emails.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/user" ) @@ -112,26 +113,34 @@ func ActivateEmail(ctx *context.Context) { uid := ctx.FormInt64("uid") email := ctx.FormString("email") - primary, okp := truefalse[ctx.FormString("primary")] activate, oka := truefalse[ctx.FormString("activate")] - if uid == 0 || len(email) == 0 || !okp || !oka { + if uid == 0 || len(email) == 0 || !oka { ctx.Error(http.StatusBadRequest) return } - log.Info("Changing activation for User ID: %d, email: %s, primary: %v to %v", uid, email, primary, activate) + log.Info("Changing activation for User ID: %d, email: %s to %v", uid, email, activate) - if err := user_model.ActivateUserEmail(ctx, uid, email, activate); err != nil { - log.Error("ActivateUserEmail(%v,%v,%v): %v", uid, email, activate, err) - if user_model.IsErrEmailAlreadyUsed(err) { - ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active")) + u, err := user_model.GetUserByID(ctx, uid) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) + } else { + if email, err := user_model.ActivateUserEmail(ctx, uid, email, activate); err != nil { + log.Error("ActivateUserEmail(%v,%v,%v): %v", uid, email, activate, err) + if user_model.IsErrEmailAlreadyUsed(err) { + ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active")) + } else { + ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) + } } else { - ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) + if activate { + audit.RecordUserEmailActivate(ctx, ctx.Doer, u, email) + } + + log.Info("Activation for User ID: %d, email: %s changed to %v", uid, email, activate) + ctx.Flash.Info(ctx.Tr("admin.emails.updated")) } - } else { - 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")) } redirect, _ := url.Parse(setting.AppSubURL + "/-/admin/emails") @@ -166,7 +175,7 @@ func DeleteEmail(ctx *context.Context) { return } - if err := user.DeleteEmailAddresses(ctx, u, []string{email.Email}); err != nil { + if err := user.DeleteEmailAddresses(ctx, ctx.Doer, u, []string{email.Email}); err != nil { if user_model.IsErrPrimaryEmailCannotDelete(err) { ctx.Flash.Error(ctx.Tr("admin.emails.delete_primary_email_error")) ctx.JSONRedirect("") diff --git a/routers/web/admin/hooks.go b/routers/web/admin/hooks.go index 91ca6e3fa7bf7..34dd3b8241e53 100644 --- a/routers/web/admin/hooks.go +++ b/routers/web/admin/hooks.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" ) @@ -61,9 +62,17 @@ func DefaultOrSystemWebhooks(ctx *context.Context) { // DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook func DeleteDefaultOrSystemWebhook(ctx *context.Context) { + hook, err := webhook.GetSystemOrDefaultWebhook(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetSystemOrDefaultWebhook", err) + return + } + if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error()) } else { + audit.RecordWebhookRemove(ctx, ctx.Doer, nil, nil, hook) + 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 a6b0b5c78bb13..12c502dd763da 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/explore" user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" @@ -207,6 +208,8 @@ func NewUserPost(ctx *context.Context) { ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email)) } + audit.RecordUserCreate(ctx, ctx.Doer, u) + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) // Send email notification. @@ -348,7 +351,7 @@ func EditUserPost(ctx *context.Context) { } if form.UserName != "" { - if err := user_service.RenameUser(ctx, u, form.UserName); err != nil { + if err := user_service.RenameUser(ctx, ctx.Doer, u, form.UserName); err != nil { switch { case user_model.IsErrUserIsNotLocal(err): ctx.Data["Err_UserName"] = true @@ -391,7 +394,7 @@ func EditUserPost(ctx *context.Context) { authOpts.LoginSource = optional.Some(authSource) } - if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil { + if err := user_service.UpdateAuth(ctx, ctx.Doer, u, authOpts); err != nil { switch { case errors.Is(err, password.ErrMinLength): ctx.Data["Err_Password"] = true @@ -406,13 +409,13 @@ func EditUserPost(ctx *context.Context) { ctx.Data["Err_Password"] = true ctx.RenderWithErr(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) default: - ctx.ServerError("UpdateUser", err) + ctx.ServerError("UpdateAuth", err) } return } if form.Email != "" { - if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, u, form.Email); err != nil { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.Doer, u, form.Email); err != nil { switch { case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): ctx.Data["Err_Email"] = true @@ -445,7 +448,7 @@ func EditUserPost(ctx *context.Context) { Language: optional.Some(form.Language), } - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, u, opts); err != nil { if models.IsErrDeleteLastAdminUser(err) { ctx.RenderWithErr(ctx.Tr("auth.last_admin"), tplUserEdit, &form) } else { @@ -499,7 +502,7 @@ func DeleteUser(ctx *context.Context) { return } - if err = user_service.DeleteUser(ctx, u, ctx.FormBool("purge")); err != nil { + if err = user_service.DeleteUser(ctx, ctx.Doer, u, ctx.FormBool("purge")); err != nil { switch { case models.IsErrUserOwnRepos(err): ctx.Flash.Error(ctx.Tr("admin.users.still_own_repo")) diff --git a/routers/web/auth/2fa.go b/routers/web/auth/2fa.go index f93177bf96b8b..1fe62aa4b0992 100644 --- a/routers/web/auth/2fa.go +++ b/routers/web/auth/2fa.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" @@ -52,6 +53,13 @@ func TwoFactorPost(ctx *context.Context) { } id := idSess.(int64) + + u, err := user_model.GetUserByID(ctx, id) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + twofa, err := auth.GetTwoFactorByUID(ctx, id) if err != nil { ctx.ServerError("UserSignIn", err) @@ -67,11 +75,6 @@ func TwoFactorPost(ctx *context.Context) { if ok && twofa.LastUsedPasscode != form.Passcode { remember := ctx.Session.Get("twofaRemember").(bool) - u, err := user_model.GetUserByID(ctx, id) - if err != nil { - ctx.ServerError("UserSignIn", err) - return - } if ctx.Session.Get("linkAccount") != nil { err = externalaccount.LinkAccountFromStore(ctx, ctx.Session, u) @@ -91,6 +94,8 @@ func TwoFactorPost(ctx *context.Context) { return } + audit.RecordUserAuthenticationFailTwoFactor(ctx, u) + ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{}) } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 3f16da3cddf8e..91078bf02d092 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/audit" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/context" @@ -109,7 +110,7 @@ func resetLocale(ctx *context.Context, u *user_model.User) error { opts := &user_service.UpdateOptions{ Language: optional.Some(ctx.Locale.Language()), } - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(ctx, u, u, opts); err != nil { return err } } @@ -333,7 +334,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe opts := &user_service.UpdateOptions{ Language: optional.Some(ctx.Locale.Language()), } - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(ctx, u, u, opts); err != nil { ctx.ServerError("UpdateUser Language", fmt.Errorf("Error updating user language [user: %d, locale: %s]", u.ID, ctx.Locale.Language())) return setting.AppSubURL + "/" } @@ -349,7 +350,7 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember, obeyRe ctx.Csrf.PrepareForSessionUser(ctx) // Register last login - if err := user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + if err := user_service.UpdateUser(ctx, u, u, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { ctx.ServerError("UpdateUser", err) return setting.AppSubURL + "/" } @@ -589,6 +590,9 @@ func createUserInContext(ctx *context.Context, tpl base.TplName, form any, u *us } return false } + + audit.RecordUserCreate(ctx, user_model.NewAuthenticationSourceUser(), u) + log.Trace("Account created: %s", u.Name) return true } @@ -604,7 +608,7 @@ func handleUserCreated(ctx *context.Context, u *user_model.User, gothUser *goth. IsAdmin: optional.Some(true), SetLastLogin: true, } - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(ctx, u, u, opts); err != nil { ctx.ServerError("UpdateUser", err) return false } @@ -773,12 +777,16 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { return } - if err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true); err != nil { + email, err := user_model.ActivateUserEmail(ctx, user.ID, user.Email, true) + if err != nil { log.Error("Unable to activate email for user: %-v with email: %s: %v", user, user.Email, err) ctx.ServerError("ActivateUserEmail", err) return } + audit.RecordUserActive(ctx, user, user) + audit.RecordUserEmailActivate(ctx, user, user, email) + log.Trace("User activated: %s", user.Name) if err := updateSession(ctx, nil, map[string]any{ @@ -797,7 +805,7 @@ func handleAccountActivation(ctx *context.Context, user *user_model.User) { return } - if err := user_service.UpdateUser(ctx, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { + if err := user_service.UpdateUser(ctx, user, user, &user_service.UpdateOptions{SetLastLogin: true}); err != nil { ctx.ServerError("UpdateUser", err) return } @@ -818,7 +826,7 @@ func ActivateEmail(ctx *context.Context) { emailStr := ctx.FormString("email") // Verify code. - if email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); email != nil { + if user, email := user_model.VerifyActiveEmailCode(ctx, code, emailStr); user != nil && email != nil { if err := user_model.ActivateEmail(ctx, email); err != nil { ctx.ServerError("ActivateEmail", err) return @@ -827,12 +835,10 @@ func ActivateEmail(ctx *context.Context) { log.Trace("Email activated: %s", email.Email) ctx.Flash.Success(ctx.Tr("settings.add_email_success")) - if u, err := user_model.GetUserByID(ctx, email.UID); err != nil { - log.Warn("GetUserByID: %d", email.UID) - } else { - // Allow user to validate more emails - _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) - } + // Allow user to validate more emails + _ = ctx.Cache.Delete("MailResendLimit_" + user.LowerName) + + audit.RecordUserEmailActivate(ctx, user, user, email) } // FIXME: e-mail verification does not require the user to be logged in, diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 75f94de0edad7..005a2b4d31629 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -347,7 +347,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model // Register last login opts.SetLastLogin = true - if err := user_service.UpdateUser(ctx, u, opts); err != nil { + if err := user_service.UpdateUser(ctx, user_model.NewAuthenticationSourceUser(), u, opts); err != nil { ctx.ServerError("UpdateUser", err) return } @@ -379,7 +379,7 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model } 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(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 1aebc047bd3d7..5972099887110 100644 --- a/routers/web/auth/oauth2_provider.go +++ b/routers/web/auth/oauth2_provider.go @@ -18,7 +18,9 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" auth_service "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -404,6 +406,18 @@ func GrantApplicationOAuth(ctx *context.Context) { return } + owner, err := user_model.GetUserByID(ctx, app.UID) + if err != nil && !errors.Is(err, util.ErrNotExist) { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot find user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + + audit.RecordUserOAuth2ApplicationGrant(ctx, ctx.Doer, owner, app, grant) + if len(form.Nonce) > 0 { err := grant.SetNonce(ctx, form.Nonce) if err != nil { diff --git a/routers/web/auth/openid.go b/routers/web/auth/openid.go index 83268faacb6ad..bb4026440f710 100644 --- a/routers/web/auth/openid.go +++ b/routers/web/auth/openid.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/auth" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -280,6 +281,8 @@ func ConnectOpenIDPost(ctx *context.Context) { return } + audit.RecordUserOpenIDAdd(ctx, u, u, userOID) + ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) remember, _ := ctx.Session.Get("openid_signin_remember").(bool) @@ -385,6 +388,8 @@ func RegisterOpenIDPost(ctx *context.Context) { return } + audit.RecordUserOpenIDAdd(ctx, u, u, userOID) + remember, _ := ctx.Session.Get("openid_signin_remember").(bool) log.Trace("Session stored openid-remember: %t", remember) handleSignIn(ctx, u, remember) diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index 334d864c6a7ff..0d61f2b3b2341 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/mailer" @@ -88,6 +89,8 @@ func ForgotPasswdPost(ctx *context.Context) { mailer.SendResetPasswordMail(u) + audit.RecordUserPasswordResetRequest(ctx, user_model.NewGhostUser(), u) + if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil { log.Error("Set cache(MailResendLimit) fail: %v", err) } @@ -185,6 +188,8 @@ func ResetPasswdPost(ctx *context.Context) { return } if !ok || twofa.LastUsedPasscode == passcode { + audit.RecordUserAuthenticationFailTwoFactor(ctx, u) + ctx.Data["IsResetForm"] = true ctx.Data["Err_Passcode"] = true ctx.RenderWithErr(ctx.Tr("auth.twofa_passcode_incorrect"), tplResetPassword, nil) @@ -203,7 +208,7 @@ func ResetPasswdPost(ctx *context.Context) { Password: optional.Some(ctx.FormString("password")), MustChangePassword: optional.Some(false), } - if err := user_service.UpdateAuth(ctx, u, opts); err != nil { + if err := user_service.UpdateAuth(ctx, ctx.Doer, u, opts); err != nil { ctx.Data["IsResetForm"] = true ctx.Data["Err_Password"] = true switch { @@ -221,7 +226,6 @@ func ResetPasswdPost(ctx *context.Context) { return } - log.Trace("User password reset: %s", u.Name) ctx.Data["IsResetFailed"] = true remember := len(ctx.FormString("remember")) != 0 @@ -285,7 +289,7 @@ func MustChangePasswordPost(ctx *context.Context) { Password: optional.Some(form.Password), MustChangePassword: optional.Some(false), } - if err := user_service.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + if err := user_service.UpdateAuth(ctx, ctx.Doer, ctx.Doer, opts); err != nil { switch { case errors.Is(err, password.ErrMinLength): ctx.Data["Err_Password"] = true @@ -307,8 +311,6 @@ func MustChangePasswordPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.change_password_success")) - log.Trace("User updated password: %s", ctx.Doer.Name) - if redirectTo := ctx.GetSiteCookie("redirect_to"); redirectTo != "" { middleware.DeleteRedirectToCookie(ctx.Resp) ctx.RedirectToCurrentSite(redirectTo) diff --git a/routers/web/org/org.go b/routers/web/org/org.go index f94dd16eaef96..ade88fe99c989 100644 --- a/routers/web/org/org.go +++ b/routers/web/org/org.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -74,6 +75,9 @@ func CreatePost(ctx *context.Context) { } return } + + audit.RecordUserCreate(ctx, ctx.Doer, org.AsUser()) + log.Trace("Organization created: %s", org.Name) ctx.Redirect(org.AsUser().DashboardLink()) diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index 494ada4323a66..eb5e364001483 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" user_setting "code.gitea.io/gitea/routers/web/user/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" org_service "code.gitea.io/gitea/services/org" @@ -73,7 +74,7 @@ func SettingsPost(ctx *context.Context) { org := ctx.Org.Organization if org.Name != form.Name { - if err := user_service.RenameUser(ctx, org.AsUser(), form.Name); err != nil { + if err := user_service.RenameUser(ctx, ctx.Doer, org.AsUser(), form.Name); err != nil { if user_model.IsErrUserAlreadyExist(err) { ctx.Data["Err_Name"] = true ctx.RenderWithErr(ctx.Tr("form.username_been_taken"), tplSettingsOptions, &form) @@ -93,13 +94,15 @@ func SettingsPost(ctx *context.Context) { } if form.Email != "" { - if err := user_service.ReplacePrimaryEmailAddress(ctx, org.AsUser(), form.Email); err != nil { + if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Doer, org.AsUser(), form.Email); err != nil { ctx.Data["Err_Email"] = true ctx.RenderWithErr(ctx.Tr("form.email_invalid"), tplSettingsOptions, &form) return } } + oldVisibility := org.Visibility + opts := &user_service.UpdateOptions{ FullName: optional.Some(form.FullName), Description: optional.Some(form.Description), @@ -111,16 +114,13 @@ func SettingsPost(ctx *context.Context) { if ctx.Doer.IsAdmin { opts.MaxRepoCreation = optional.Some(form.MaxRepoCreation) } - - visibilityChanged := org.Visibility != form.Visibility - - if err := user_service.UpdateUser(ctx, org.AsUser(), opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, org.AsUser(), opts); err != nil { ctx.ServerError("UpdateUser", err) return } // update forks visibility - if visibilityChanged { + if org.Visibility != oldVisibility { repos, _, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ Actor: org.AsUser(), Private: true, ListOptions: db.ListOptions{Page: 1, PageSize: org.NumRepos}, }) @@ -177,7 +177,7 @@ func SettingsDelete(ctx *context.Context) { return } - if err := org_service.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { + if err := org_service.DeleteOrganization(ctx, ctx.Doer, ctx.Org.Organization, false); err != nil { if models.IsErrUserOwnRepos(err) { ctx.Flash.Error(ctx.Tr("form.org_still_own_repo")) ctx.Redirect(ctx.Org.OrgLink + "/settings/delete") @@ -230,9 +230,17 @@ func Webhooks(ctx *context.Context) { // DeleteWebhook response for delete webhook func DeleteWebhook(ctx *context.Context) { + hook, err := webhook.GetWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetWebhookByOwnerID", err) + return + } + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { + audit.RecordWebhookRemove(ctx, ctx.Doer, ctx.Org.Organization.AsUser(), nil, hook) + 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 0000000000000..ea9af92e97d2f --- /dev/null +++ b/routers/web/org/setting/audit.go @@ -0,0 +1,56 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + audit_model "code.gitea.io/gitea/models/audit" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" + "code.gitea.io/gitea/services/context" +) + +const ( + tplAuditLogs base.TplName = "org/settings/audit_logs" +) + +func ViewAuditLogs(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.audit.title") + ctx.Data["PageIsOrgSettings"] = true + ctx.Data["PageIsSettingsAudit"] = true + + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + opts := &audit_model.EventSearchOptions{ + Sort: ctx.FormString("sort"), + ScopeType: audit_model.TypeOrganization, + ScopeID: ctx.ContextUser.ID, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.NoticePagingNum, + }, + } + + ctx.Data["AuditSort"] = opts.Sort + + evs, total, err := audit.FindEvents(ctx, opts) + if err != nil { + ctx.ServerError("", err) + return + } + + ctx.Data["AuditEvents"] = evs + + pager := context.NewPagination(int(total), setting.UI.Admin.NoticePagingNum, page, 5) + pager.AddParamString("sort", opts.Sort) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplAuditLogs) +} diff --git a/routers/web/org/setting_oauth2.go b/routers/web/org/setting_oauth2.go index 7f855795d3639..276c09ef5e266 100644 --- a/routers/web/org/setting_oauth2.go +++ b/routers/web/org/setting_oauth2.go @@ -9,6 +9,8 @@ import ( "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" + organization_model "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" shared_user "code.gitea.io/gitea/routers/web/shared/user" @@ -21,11 +23,12 @@ const ( tplSettingsOAuthApplicationEdit base.TplName = "org/settings/applications_oauth2_edit" ) -func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers { +func newOAuth2CommonHandlers(doer *user_model.User, org *organization_model.Organization) *user_setting.OAuth2CommonHandlers { return &user_setting.OAuth2CommonHandlers{ - OwnerID: org.Organization.ID, - 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), + Doer: doer, + Owner: organization_model.UserFromOrg(org), + BasePathList: fmt.Sprintf("%s/org/%s/settings/applications", setting.AppSubURL, org.Name), + BasePathEditPrefix: fmt.Sprintf("%s/org/%s/settings/applications/oauth2", setting.AppSubURL, org.Name), TplAppEdit: tplSettingsOAuthApplicationEdit, } } @@ -60,7 +63,7 @@ func OAuthApplicationsPost(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Org) + oa := newOAuth2CommonHandlers(ctx.Doer, ctx.Org.Organization) oa.AddApp(ctx) } @@ -69,7 +72,7 @@ func OAuth2ApplicationShow(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Org) + oa := newOAuth2CommonHandlers(ctx.Doer, ctx.Org.Organization) oa.EditShow(ctx) } @@ -79,7 +82,7 @@ func OAuth2ApplicationEdit(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Org) + oa := newOAuth2CommonHandlers(ctx.Doer, ctx.Org.Organization) oa.EditSave(ctx) } @@ -89,13 +92,13 @@ func OAuthApplicationsRegenerateSecret(ctx *context.Context) { ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsSettingsApplications"] = true - oa := newOAuth2CommonHandlers(ctx.Org) + oa := newOAuth2CommonHandlers(ctx.Doer, ctx.Org.Organization) oa.RegenerateSecret(ctx) } // DeleteOAuth2Application deletes the given oauth2 application func DeleteOAuth2Application(ctx *context.Context) { - oa := newOAuth2CommonHandlers(ctx.Org) + oa := newOAuth2CommonHandlers(ctx.Doer, ctx.Org.Organization) oa.DeleteApp(ctx) } diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index bd78832103251..18ed61e49109c 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" @@ -78,6 +79,9 @@ func TeamsAction(ctx *context.Context) { return } err = org_service.AddTeamMember(ctx, ctx.Org.Team, ctx.Doer) + if err == nil { + audit.RecordOrganizationTeamMemberAdd(ctx, ctx.Doer, ctx.Org.Organization, ctx.Org.Team, ctx.Doer) + } case "leave": err = org_service.RemoveTeamMember(ctx, ctx.Org.Team, ctx.Doer) if err != nil { @@ -91,6 +95,8 @@ func TeamsAction(ctx *context.Context) { }) return } + } else { + audit.RecordOrganizationTeamMemberRemove(ctx, ctx.Doer, ctx.Org.Organization, ctx.Org.Team, ctx.Doer) } checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/") return @@ -118,6 +124,8 @@ func TeamsAction(ctx *context.Context) { }) return } + } else { + audit.RecordOrganizationTeamMemberRemove(ctx, ctx.Doer, ctx.Org.Organization, ctx.Org.Team, user) } checkIsOrgMemberAndRedirect(ctx, ctx.Org.OrgLink+"/teams/"+url.PathEscape(ctx.Org.Team.LowerName)) return @@ -162,6 +170,9 @@ func TeamsAction(ctx *context.Context) { ctx.Flash.Error(ctx.Tr("org.teams.add_duplicate_users")) } else { err = org_service.AddTeamMember(ctx, ctx.Org.Team, u) + if err == nil { + audit.RecordOrganizationTeamMemberAdd(ctx, ctx.Doer, ctx.Org.Organization, ctx.Org.Team, u) + } } page = "team" @@ -232,13 +243,10 @@ func TeamsRepoAction(ctx *context.Context) { return } - var err error action := ctx.PathParam(":action") switch action { case "add": - repoName := path.Base(ctx.FormString("repo_name")) - var repo *repo_model.Repository - repo, err = repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName) + repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, path.Base(ctx.FormString("repo_name"))) if err != nil { if repo_model.IsErrRepoNotExist(err) { ctx.Flash.Error(ctx.Tr("org.teams.add_nonexistent_repo")) @@ -248,19 +256,44 @@ func TeamsRepoAction(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - err = repo_service.TeamAddRepository(ctx, ctx.Org.Team, repo) + if err := repo_service.TeamAddRepository(ctx, ctx.Doer, ctx.Org.Team, repo); err != nil { + ctx.ServerError("TeamAddRepository "+ctx.Org.Team.Name, err) + return + } case "remove": - err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, ctx.FormInt64("repoid")) + repo, err := repo_model.GetRepositoryByID(ctx, ctx.FormInt64("repoid")) + if err != nil { + ctx.ServerError("GetRepositoryByID", err) + return + } + if err := repo_service.RemoveRepositoryFromTeam(ctx, ctx.Doer, ctx.Org.Team, repo); err != nil { + ctx.ServerError("RemoveRepositoryFromTeam "+ctx.Org.Team.Name, err) + return + } case "addall": - err = repo_service.AddAllRepositoriesToTeam(ctx, ctx.Org.Team) + added, err := repo_service.AddAllRepositoriesToTeam(ctx, ctx.Org.Team) + if err != nil { + ctx.ServerError("AddAllRepositoriesToTeam "+ctx.Org.Team.Name, err) + return + } + + for _, repo := range added { + audit.RecordRepositoryCollaboratorTeamAdd(ctx, ctx.Doer, repo, ctx.Org.Team) + } case "removeall": - err = repo_service.RemoveAllRepositoriesFromTeam(ctx, ctx.Org.Team) - } + if err := ctx.Org.Team.LoadRepositories(ctx); err != nil { + ctx.ServerError("LoadRepositories "+ctx.Org.Team.Name, err) + return + } - if err != nil { - log.Error("Action(%s): '%s' %v", ctx.PathParam(":action"), ctx.Org.Team.Name, err) - ctx.ServerError("TeamsRepoAction", err) - return + if err := repo_service.RemoveAllRepositoriesFromTeam(ctx, ctx.Org.Team); err != nil { + ctx.ServerError("RemoveAllRepositoriesFromTeam "+ctx.Org.Team.Name, err) + return + } + + for _, repo := range ctx.Org.Team.Repos { + audit.RecordRepositoryCollaboratorTeamRemove(ctx, ctx.Doer, repo, ctx.Org.Team) + } } if action == "addall" || action == "removeall" { @@ -367,6 +400,9 @@ func NewTeamPost(ctx *context.Context) { } return } + + audit.RecordOrganizationTeamAdd(ctx, ctx.Doer, ctx.Org.Organization, t) + log.Trace("Team created: %s/%s", ctx.Org.Organization.Name, t.Name) ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(t.LowerName)) } @@ -545,6 +581,12 @@ func EditTeamPost(ctx *context.Context) { } return } + + audit.RecordOrganizationTeamUpdate(ctx, ctx.Doer, ctx.Org.Organization, t) + if isAuthChanged { + audit.RecordOrganizationTeamPermission(ctx, ctx.Doer, ctx.Org.Organization, t) + } + ctx.Redirect(ctx.Org.OrgLink + "/teams/" + url.PathEscape(t.LowerName)) } @@ -553,6 +595,8 @@ func DeleteTeam(ctx *context.Context) { if err := org_service.DeleteTeam(ctx, ctx.Org.Team); err != nil { ctx.Flash.Error("DeleteTeam: " + err.Error()) } else { + audit.RecordOrganizationTeamRemove(ctx, ctx.Doer, ctx.Org.Organization, ctx.Org.Team) + ctx.Flash.Success(ctx.Tr("org.teams.delete_team_success")) } @@ -597,6 +641,8 @@ func TeamInvitePost(ctx *context.Context) { return } + audit.RecordOrganizationTeamMemberAdd(ctx, ctx.Doer, org, team, ctx.Doer) + if err := org_model.RemoveInviteByID(ctx, invite.ID, team.ID); err != nil { log.Error("RemoveInviteByID: %v", err) } diff --git a/routers/web/repo/middlewares.go b/routers/web/repo/middlewares.go index 420931c5fb729..e4bf88973aecc 100644 --- a/routers/web/repo/middlewares.go +++ b/routers/web/repo/middlewares.go @@ -61,7 +61,7 @@ func SetDiffViewStyle(ctx *context.Context) { opts := &user_service.UpdateOptions{ DiffViewStyle: optional.Some(style), } - if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, ctx.Doer, opts); err != nil { ctx.ServerError("UpdateUser", err) } } diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index f5e59b0357b02..cb9f5de2e137d 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -30,6 +30,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" @@ -405,6 +406,9 @@ func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { if err := repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository); err != nil { return err } + + audit.RecordRepositoryTransferCancel(ctx, ctx.Doer, ctx.Repo.Repository) + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) } diff --git a/routers/web/repo/setting/audit.go b/routers/web/repo/setting/audit.go new file mode 100644 index 0000000000000..17d767fcd279e --- /dev/null +++ b/routers/web/repo/setting/audit.go @@ -0,0 +1,55 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + audit_model "code.gitea.io/gitea/models/audit" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" + "code.gitea.io/gitea/services/context" +) + +const ( + tplAuditLogs base.TplName = "repo/settings/audit_logs" +) + +func ViewAuditLogs(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.audit.title") + ctx.Data["PageIsSettingsAudit"] = true + + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + opts := &audit_model.EventSearchOptions{ + ScopeType: audit_model.TypeRepository, + ScopeID: ctx.Repo.Repository.ID, + Sort: ctx.FormString("sort"), + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.NoticePagingNum, + }, + } + + ctx.Data["AuditSort"] = opts.Sort + + evs, total, err := audit.FindEvents(ctx, opts) + if err != nil { + ctx.ServerError("", err) + return + } + + ctx.Data["AuditEvents"] = evs + + pager := context.NewPagination(int(total), setting.UI.Admin.NoticePagingNum, page, 5) + pager.AddParamString("sort", opts.Sort) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplAuditLogs) +} diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index cdf91edf4a2c2..27ce396bd2041 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -15,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/mailer" repo_service "code.gitea.io/gitea/services/repository" @@ -98,7 +99,7 @@ func CollaborationPost(ctx *context.Context) { } } - if err = repo_service.AddOrUpdateCollaborator(ctx, ctx.Repo.Repository, u, perm.AccessModeWrite); err != nil { + if err = repo_service.AddOrUpdateCollaborator(ctx, ctx.Doer, ctx.Repo.Repository, u, perm.AccessModeWrite); err != nil { if errors.Is(err, user_model.ErrBlockedUser) { ctx.Flash.Error(ctx.Tr("repo.settings.add_collaborator.blocked_user")) ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") @@ -118,13 +119,24 @@ func CollaborationPost(ctx *context.Context) { // ChangeCollaborationAccessMode response for changing access of a collaboration func ChangeCollaborationAccessMode(ctx *context.Context) { + u, err := user_model.GetUserByID(ctx, ctx.FormInt64("uid")) + if err != nil { + log.Error("GetUserByID: %v", err) + return + } + + accessMode := perm.AccessMode(ctx.FormInt("mode")) + if err := repo_model.ChangeCollaborationAccessMode( ctx, ctx.Repo.Repository, - ctx.FormInt64("uid"), - perm.AccessMode(ctx.FormInt("mode"))); err != nil { + u.ID, + accessMode); err != nil { log.Error("ChangeCollaborationAccessMode: %v", err) + return } + + audit.RecordRepositoryCollaboratorAccess(ctx, ctx.Doer, ctx.Repo.Repository, u, accessMode) } // DeleteCollaboration delete a collaboration for a repository @@ -137,7 +149,7 @@ func DeleteCollaboration(ctx *context.Context) { return } } else { - if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator); err != nil { + if err := repo_service.DeleteCollaboration(ctx, ctx.Doer, ctx.Repo.Repository, collaborator); err != nil { ctx.Flash.Error("DeleteCollaboration: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.settings.remove_collaborator_success")) @@ -184,7 +196,7 @@ func AddTeamPost(ctx *context.Context) { return } - if err = repo_service.TeamAddRepository(ctx, team, ctx.Repo.Repository); err != nil { + if err = repo_service.TeamAddRepository(ctx, ctx.Doer, team, ctx.Repo.Repository); err != nil { ctx.ServerError("TeamAddRepository", err) return } @@ -207,11 +219,13 @@ func DeleteTeam(ctx *context.Context) { return } - if err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID); err != nil { + if err = repo_service.RemoveRepositoryFromTeam(ctx, ctx.Doer, team, ctx.Repo.Repository); err != nil { ctx.ServerError("team.RemoveRepositorys", err) return } + audit.RecordRepositoryCollaboratorTeamRemove(ctx, ctx.Doer, ctx.Repo.Repository, team) + ctx.Flash.Success(ctx.Tr("repo.settings.remove_team_success")) ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/collaboration") } diff --git a/routers/web/repo/setting/default_branch.go b/routers/web/repo/setting/default_branch.go index 881d148afc45c..42575c9821ad7 100644 --- a/routers/web/repo/setting/default_branch.go +++ b/routers/web/repo/setting/default_branch.go @@ -34,7 +34,7 @@ func SetDefaultBranchPost(ctx *context.Context) { } branch := ctx.FormString("branch") - if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil { + if err := repo_service.SetRepoDefaultBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branch); err != nil { switch { case git_model.IsErrBranchNotExist(err): ctx.Status(http.StatusNotFound) diff --git a/routers/web/repo/setting/deploy_key.go b/routers/web/repo/setting/deploy_key.go index abc3eb4af18b6..901a379cfa609 100644 --- a/routers/web/repo/setting/deploy_key.go +++ b/routers/web/repo/setting/deploy_key.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -92,6 +93,8 @@ func DeployKeysPost(ctx *context.Context) { return } + audit.RecordRepositoryDeployKeyAdd(ctx, ctx.Doer, ctx.Repo.Repository, key) + log.Trace("Deploy key added: %d", ctx.Repo.Repository.ID) ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name)) ctx.Redirect(ctx.Repo.RepoLink + "/settings/keys") diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index f651d8f318db1..0fad62747afe9 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/repo" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" pull_service "code.gitea.io/gitea/services/pull" @@ -258,6 +259,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) { protectBranch.BlockOnOutdatedBranch = f.BlockOnOutdatedBranch protectBranch.BlockAdminMergeOverride = f.BlockAdminMergeOverride + isNewProtectedBranch := protectBranch.ID == 0 + err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ UserIDs: whitelistUsers, TeamIDs: whitelistTeams, @@ -273,6 +276,12 @@ func SettingsProtectedBranchPost(ctx *context.Context) { return } + if isNewProtectedBranch { + audit.RecordRepositoryBranchProtectionAdd(ctx, ctx.Doer, ctx.Repo.Repository, protectBranch) + } else { + audit.RecordRepositoryBranchProtectionUpdate(ctx, ctx.Doer, ctx.Repo.Repository, protectBranch) + } + // FIXME: since we only need to recheck files protected rules, we could improve this matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, protectBranch.RuleName) if err != nil { @@ -318,6 +327,8 @@ func DeleteProtectedBranchRulePost(ctx *context.Context) { return } + audit.RecordRepositoryBranchProtectionRemove(ctx, ctx.Doer, ctx.Repo.Repository, rule) + ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName)) ctx.JSONRedirect(fmt.Sprintf("%s/settings/branches", ctx.Repo.RepoLink)) } diff --git a/routers/web/repo/setting/protected_tag.go b/routers/web/repo/setting/protected_tag.go index fcfa77aa8ce04..b90828236c79b 100644 --- a/routers/web/repo/setting/protected_tag.go +++ b/routers/web/repo/setting/protected_tag.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -63,6 +64,8 @@ func NewProtectedTagPost(ctx *context.Context) { return } + audit.RecordRepositoryTagProtectionAdd(ctx, ctx.Doer, repo, pt) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) } @@ -116,6 +119,8 @@ func EditProtectedTagPost(ctx *context.Context) { return } + audit.RecordRepositoryTagProtectionUpdate(ctx, ctx.Doer, ctx.Repo.Repository, pt) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags") } @@ -132,6 +137,8 @@ func DeleteProtectedTagPost(ctx *context.Context) { return } + audit.RecordRepositoryTagProtectionRemove(ctx, ctx.Doer, ctx.Repo.Repository, pt) + 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 df1172934437d..587a0acf7d38b 100644 --- a/routers/web/repo/setting/secrets.go +++ b/routers/web/repo/setting/secrets.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" @@ -23,8 +24,8 @@ const ( ) type secretsCtx struct { - OwnerID int64 - RepoID int64 + Owner *user_model.User + Repo *repo_model.Repository IsRepo bool IsOrg bool IsUser bool @@ -35,8 +36,8 @@ type secretsCtx struct { func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { if ctx.Data["PageIsRepoSettings"] == true { return &secretsCtx{ - OwnerID: 0, - RepoID: ctx.Repo.Repository.ID, + Owner: nil, + Repo: ctx.Repo.Repository, IsRepo: true, SecretsTemplate: tplRepoSecrets, RedirectLink: ctx.Repo.RepoLink + "/settings/actions/secrets", @@ -50,8 +51,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { return nil, nil } return &secretsCtx{ - OwnerID: ctx.ContextUser.ID, - RepoID: 0, + Owner: ctx.ContextUser, + Repo: nil, IsOrg: true, SecretsTemplate: tplOrgSecrets, RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets", @@ -60,8 +61,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) { if ctx.Data["PageIsUserSettings"] == true { return &secretsCtx{ - OwnerID: ctx.Doer.ID, - RepoID: 0, + Owner: ctx.Doer, + Repo: nil, IsUser: true, SecretsTemplate: tplUserSecrets, RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets", @@ -87,7 +88,7 @@ func Secrets(ctx *context.Context) { ctx.Data["DisableSSH"] = setting.SSH.Disabled } - shared.SetSecretsContext(ctx, sCtx.OwnerID, sCtx.RepoID) + shared.SetSecretsContext(ctx, sCtx.Owner, sCtx.Repo) if ctx.Written() { return } @@ -108,8 +109,9 @@ func SecretsPost(ctx *context.Context) { shared.PerformSecretsPost( ctx, - sCtx.OwnerID, - sCtx.RepoID, + ctx.Doer, + sCtx.Owner, + sCtx.Repo, sCtx.RedirectLink, ) } @@ -122,8 +124,9 @@ func SecretsDelete(ctx *context.Context) { } shared.PerformSecretsDelete( ctx, - sCtx.OwnerID, - sCtx.RepoID, + ctx.Doer, + sCtx.Owner, + sCtx.Repo, sCtx.RedirectLink, ) } diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index e30129bb44ccd..603c3f1f1cd48 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -32,6 +32,7 @@ import ( "code.gitea.io/gitea/modules/web" actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" @@ -173,6 +174,7 @@ func SettingsPost(ctx *context.Context) { ctx.ServerError("UpdateRepository", err) return } + log.Trace("Repository basic settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) @@ -364,6 +366,8 @@ func SettingsPost(ctx *context.Context) { return } + audit.RecordRepositoryMirrorPushRemove(ctx, ctx.Doer, repo, m) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(repo.Link() + "/settings") @@ -428,6 +432,8 @@ func SettingsPost(ctx *context.Context) { return } + audit.RecordRepositoryMirrorPushAdd(ctx, ctx.Doer, repo, m) + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(repo.Link() + "/settings") @@ -613,6 +619,7 @@ func SettingsPost(ctx *context.Context) { return } } + log.Trace("Repository advanced settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) @@ -631,6 +638,8 @@ func SettingsPost(ctx *context.Context) { ctx.ServerError("UpdateRepository", err) return } + + audit.RecordRepositorySigningVerification(ctx, ctx.Doer, repo) } log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name) @@ -708,6 +717,9 @@ func SettingsPost(ctx *context.Context) { ctx.ServerError("DeleteMirrorByRepoID", err) return } + + audit.RecordRepositoryConvertMirror(ctx, ctx.Doer, repo) + log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) ctx.Redirect(repo.Link()) @@ -739,7 +751,7 @@ func SettingsPost(ctx *context.Context) { return } - if err := repo_service.ConvertForkToNormalRepository(ctx, repo); err != nil { + if err := repo_service.ConvertForkToNormalRepository(ctx, ctx.Doer, repo); err != nil { log.Error("Unable to convert repository %-v from fork. Error: %v", repo, err) ctx.ServerError("Convert Fork", err) return @@ -792,7 +804,7 @@ func SettingsPost(ctx *context.Context) { } else if errors.Is(err, user_model.ErrBlockedUser) { ctx.RenderWithErr(ctx.Tr("repo.settings.transfer.blocked_user"), tplSettingsOptions, nil) } else { - ctx.ServerError("TransferOwnership", err) + ctx.ServerError("StartRepositoryTransfer", err) } return @@ -835,6 +847,8 @@ func SettingsPost(ctx *context.Context) { return } + audit.RecordRepositoryTransferCancel(ctx, ctx.Doer, ctx.Repo.Repository) + log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) ctx.Redirect(repo.Link() + "/settings") @@ -873,10 +887,11 @@ func SettingsPost(ctx *context.Context) { return } - err := wiki_service.DeleteWiki(ctx, repo) - if err != nil { - log.Error("Delete Wiki: %v", err.Error()) + if err := wiki_service.DeleteWiki(ctx, ctx.Doer, repo); err != nil { + ctx.ServerError("DeleteWiki", err) + return } + log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) @@ -905,6 +920,8 @@ func SettingsPost(ctx *context.Context) { log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) } + audit.RecordRepositoryArchive(ctx, ctx.Doer, repo) + ctx.Flash.Success(ctx.Tr("repo.settings.archive.success")) log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) @@ -929,6 +946,8 @@ func SettingsPost(ctx *context.Context) { } } + audit.RecordRepositoryUnarchive(ctx, ctx.Doer, repo) + ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success")) log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) @@ -950,9 +969,9 @@ func SettingsPost(ctx *context.Context) { } if repo.IsPrivate { - err = repo_service.MakeRepoPublic(ctx, repo) + err = repo_service.MakeRepoPublic(ctx, ctx.Doer, repo) } else { - err = repo_service.MakeRepoPrivate(ctx, repo) + err = repo_service.MakeRepoPrivate(ctx, ctx.Doer, repo) } if err != nil { diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index 8d548c4e3d1ae..7fe2efd7df176 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" @@ -25,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/forms" @@ -58,8 +60,8 @@ func Webhooks(ctx *context.Context) { } type ownerRepoCtx struct { - OwnerID int64 - RepoID int64 + Owner *user_model.User + Repo *repo_model.Repository IsAdmin bool IsSystemWebhook bool Link string @@ -71,7 +73,7 @@ type ownerRepoCtx struct { func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { if ctx.Data["PageIsRepoSettings"] == true { return &ownerRepoCtx{ - RepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"), LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"), NewTemplate: tplHookNew, @@ -80,7 +82,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { if ctx.Data["PageIsOrgSettings"] == true { return &ownerRepoCtx{ - OwnerID: ctx.ContextUser.ID, + Owner: ctx.ContextUser, Link: path.Join(ctx.Org.OrgLink, "settings/hooks"), LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"), NewTemplate: tplOrgHookNew, @@ -89,7 +91,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { if ctx.Data["PageIsUserSettings"] == true { return &ownerRepoCtx{ - OwnerID: ctx.Doer.ID, + Owner: ctx.Doer, Link: path.Join(setting.AppSubURL, "/user/settings/hooks"), LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"), NewTemplate: tplUserHookNew, @@ -229,8 +231,17 @@ func createWebhook(ctx *context.Context, params webhookParams) { } } + repoID := int64(0) + if orCtx.Repo != nil { + repoID = orCtx.Repo.ID + } + ownerID := int64(0) + if orCtx.Owner != nil { + ownerID = orCtx.Owner.ID + } + w := &webhook.Webhook{ - RepoID: orCtx.RepoID, + RepoID: repoID, URL: params.URL, HTTPMethod: params.HTTPMethod, ContentType: params.ContentType, @@ -239,7 +250,7 @@ func createWebhook(ctx *context.Context, params webhookParams) { IsActive: params.WebhookForm.Active, Type: params.Type, Meta: string(meta), - OwnerID: orCtx.OwnerID, + OwnerID: ownerID, IsSystemWebhook: orCtx.IsSystemWebhook, } err = w.SetHeaderAuthorization(params.WebhookForm.AuthorizationHeader) @@ -255,6 +266,8 @@ func createWebhook(ctx *context.Context, params webhookParams) { return } + audit.RecordWebhookAdd(ctx, ctx.Doer, orCtx.Owner, orCtx.Repo, w) + ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success")) ctx.Redirect(orCtx.Link) } @@ -307,6 +320,8 @@ func editWebhook(ctx *context.Context, params webhookParams) { return } + audit.RecordWebhookUpdate(ctx, ctx.Doer, orCtx.Owner, orCtx.Repo, w) + ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success")) ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID)) } @@ -591,10 +606,10 @@ func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { ctx.Data["BaseLinkNew"] = orCtx.LinkNew var w *webhook.Webhook - if orCtx.RepoID > 0 { - w, err = webhook.GetWebhookByRepoID(ctx, orCtx.RepoID, ctx.PathParamInt64(":id")) - } else if orCtx.OwnerID > 0 { - w, err = webhook.GetWebhookByOwnerID(ctx, orCtx.OwnerID, ctx.PathParamInt64(":id")) + if orCtx.Repo != nil { + w, err = webhook.GetWebhookByRepoID(ctx, orCtx.Repo.ID, ctx.PathParamInt64(":id")) + } else if orCtx.Owner != nil { + w, err = webhook.GetWebhookByOwnerID(ctx, orCtx.Owner.ID, ctx.PathParamInt64(":id")) } else if orCtx.IsAdmin { w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.PathParamInt64(":id")) } @@ -728,9 +743,17 @@ func ReplayWebhook(ctx *context.Context) { // DeleteWebhook delete a webhook func DeleteWebhook(ctx *context.Context) { + hook, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")) + if err != nil { + ctx.ServerError("GetWebhookByRepoID", err) + return + } + if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil { ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error()) } else { + audit.RecordWebhookRemove(ctx, ctx.Doer, nil, ctx.Repo.Repository, hook) + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index 3bd421f86ab5c..2432de0e935d6 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -5,7 +5,9 @@ package secrets import ( "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -14,7 +16,16 @@ import ( secret_service "code.gitea.io/gitea/services/secrets" ) -func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { +func SetSecretsContext(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository) { + ownerID := int64(0) + if owner != nil { + ownerID = owner.ID + } + repoID := int64(0) + if repo != nil { + repoID = repo.ID + } + secrets, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{OwnerID: ownerID, RepoID: repoID}) if err != nil { ctx.ServerError("FindSecrets", err) @@ -24,10 +35,10 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { ctx.Data["Secrets"] = secrets } -func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { +func PerformSecretsPost(ctx *context.Context, doer, owner *user_model.User, repo *repo_model.Repository, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data)) + s, _, err := secret_service.CreateOrUpdateSecret(ctx, doer, owner, repo, form.Name, util.ReserveLineBreakForTextarea(form.Data)) if err != nil { log.Error("CreateOrUpdateSecret failed: %v", err) ctx.JSONError(ctx.Tr("secrets.creation.failed")) @@ -38,10 +49,10 @@ func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL ctx.JSONRedirect(redirectURL) } -func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) { +func PerformSecretsDelete(ctx *context.Context, doer, owner *user_model.User, repo *repo_model.Repository, redirectURL string) { id := ctx.FormInt64("id") - err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id) + err := secret_service.DeleteSecretByID(ctx, doer, owner, repo, id) if err != nil { log.Error("DeleteSecretByID(%d) failed: %v", id, err) ctx.JSONError(ctx.Tr("secrets.deletion.failed")) diff --git a/routers/web/user/setting/account.go b/routers/web/user/setting/account.go index 7f2dece416971..469f2e4861e2b 100644 --- a/routers/web/user/setting/account.go +++ b/routers/web/user/setting/account.go @@ -78,7 +78,7 @@ func AccountPost(ctx *context.Context) { Password: optional.Some(form.Password), MustChangePassword: optional.Some(false), } - if err := user.UpdateAuth(ctx, ctx.Doer, opts); err != nil { + if err := user.UpdateAuth(ctx, ctx.Doer, ctx.Doer, opts); err != nil { switch { case errors.Is(err, password.ErrMinLength): ctx.Flash.Error(ctx.Tr("auth.password_too_short", setting.MinPasswordLength)) @@ -185,7 +185,7 @@ func EmailPost(ctx *context.Context) { opts := &user.UpdateOptions{ EmailNotificationsPreference: optional.Some(preference), } - if err := user.UpdateUser(ctx, ctx.Doer, opts); err != nil { + if err := user.UpdateUser(ctx, ctx.Doer, ctx.Doer, opts); err != nil { log.Error("Set Email Notifications failed: %v", err) ctx.ServerError("UpdateUser", err) return @@ -203,7 +203,7 @@ func EmailPost(ctx *context.Context) { return } - if err := user.AddEmailAddresses(ctx, ctx.Doer, []string{form.Email}); err != nil { + if err := user.AddEmailAddresses(ctx, ctx.Doer, ctx.Doer, []string{form.Email}); err != nil { if user_model.IsErrEmailAlreadyUsed(err) { loadAccountData(ctx) @@ -230,7 +230,6 @@ func EmailPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("settings.add_email_success")) } - log.Trace("Email address added: %s", form.Email) ctx.Redirect(setting.AppSubURL + "/user/settings/account") } @@ -246,11 +245,10 @@ func DeleteEmail(ctx *context.Context) { return } - if err := user.DeleteEmailAddresses(ctx, ctx.Doer, []string{email.Email}); err != nil { + if err := user.DeleteEmailAddresses(ctx, ctx.Doer, ctx.Doer, []string{email.Email}); err != nil { ctx.ServerError("DeleteEmailAddresses", err) return } - log.Trace("Email address deleted: %s", ctx.Doer.Name) ctx.Flash.Success(ctx.Tr("settings.email_deletion_success")) ctx.JSONRedirect(setting.AppSubURL + "/user/settings/account") @@ -299,7 +297,7 @@ func DeleteAccount(ctx *context.Context) { return } - if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil { + if err := user.DeleteUser(ctx, ctx.Doer, ctx.Doer, false); err != nil { switch { case models.IsErrUserOwnRepos(err): ctx.Flash.Error(ctx.Tr("form.still_own_repo")) diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index 356c2ea5de37f..54382497ef7f5 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -73,6 +74,8 @@ func ApplicationsPost(ctx *context.Context) { return } + audit.RecordUserAccessTokenAdd(ctx, ctx.Doer, ctx.Doer, t) + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) ctx.Flash.Info(t.Token) @@ -81,9 +84,17 @@ 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.ServerError("GetAccessTokenByID", err) + return + } + + if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.Doer.ID); err != nil { ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) } else { + audit.RecordUserAccessTokenRemove(ctx, ctx.Doer, ctx.Doer, t) + 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 0000000000000..83acc4e4b3a69 --- /dev/null +++ b/routers/web/user/setting/audit.go @@ -0,0 +1,55 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + audit_model "code.gitea.io/gitea/models/audit" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" + "code.gitea.io/gitea/services/context" +) + +const ( + tplAuditLogs base.TplName = "user/settings/audit_logs" +) + +func ViewAuditLogs(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.monitor.audit.title") + ctx.Data["PageIsSettingsAudit"] = true + + page := ctx.FormInt("page") + if page < 1 { + page = 1 + } + + opts := &audit_model.EventSearchOptions{ + Sort: ctx.FormString("sort"), + ScopeType: audit_model.TypeUser, + ScopeID: ctx.Doer.ID, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.NoticePagingNum, + }, + } + + ctx.Data["AuditSort"] = opts.Sort + + evs, total, err := audit.FindEvents(ctx, opts) + if err != nil { + ctx.ServerError("", err) + return + } + + ctx.Data["AuditEvents"] = evs + + pager := context.NewPagination(int(total), setting.UI.Admin.NoticePagingNum, page, 5) + pager.AddParamString("sort", opts.Sort) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplAuditLogs) +} diff --git a/routers/web/user/setting/keys.go b/routers/web/user/setting/keys.go index c492715fb5f71..a856f999a801a 100644 --- a/routers/web/user/setting/keys.go +++ b/routers/web/user/setting/keys.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -70,7 +71,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): @@ -83,6 +85,9 @@ func KeysPost(ctx *context.Context) { } return } + + audit.RecordUserKeyPrincipalAdd(ctx, ctx.Doer, ctx.Doer, key) + ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "gpg": @@ -131,6 +136,11 @@ func KeysPost(ctx *context.Context) { } return } + + for _, key := range keys { + audit.RecordUserKeyGPGAdd(ctx, ctx.Doer, ctx.Doer, key) + } + keyIDs := "" for _, key := range keys { keyIDs += key.KeyID @@ -187,7 +197,8 @@ func KeysPost(ctx *context.Context) { return } - if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0); err != nil { + key, err := asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0) + if err != nil { ctx.Data["HasSSHError"] = true switch { case asymkey_model.IsErrKeyAlreadyExist(err): @@ -208,6 +219,9 @@ func KeysPost(ctx *context.Context) { } return } + + audit.RecordUserKeySSHAdd(ctx, ctx.Doer, ctx.Doer, key) + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) ctx.Redirect(setting.AppSubURL + "/user/settings/keys") case "verify_ssh": @@ -252,10 +266,23 @@ func DeleteKey(ctx *context.Context) { ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) return } - if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil { - ctx.Flash.Error("DeleteGPGKey: " + err.Error()) - } else { + + 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 key != nil { + if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, key.ID); err != nil { + ctx.ServerError("DeleteGPGKey", err) + return + } + + audit.RecordUserKeyGPGRemove(ctx, ctx.Doer, ctx.Doer, key) + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } else { + ctx.Flash.Error(ctx.Tr("error.occurred")) } case "ssh": if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { @@ -266,7 +293,7 @@ func DeleteKey(ctx *context.Context) { keyID := ctx.FormInt64("id") external, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, keyID) if err != nil { - ctx.ServerError("sshKeysExternalManaged", err) + ctx.ServerError("PublicKeyIsExternallyManaged", err) return } if external { diff --git a/routers/web/user/setting/oauth2.go b/routers/web/user/setting/oauth2.go index 1f485e06c815e..a1a1c1b2c77d6 100644 --- a/routers/web/user/setting/oauth2.go +++ b/routers/web/user/setting/oauth2.go @@ -4,6 +4,7 @@ package setting import ( + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/services/context" @@ -13,9 +14,9 @@ const ( tplSettingsOAuthApplicationEdit base.TplName = "user/settings/applications_oauth2_edit" ) -func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers { +func newOAuth2CommonHandlers(u *user_model.User) *OAuth2CommonHandlers { return &OAuth2CommonHandlers{ - OwnerID: userID, + Owner: u, 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") 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") 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") 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 e93e9e19548b4..93c33fdebd991 100644 --- a/routers/web/user/setting/oauth2_common.go +++ b/routers/web/user/setting/oauth2_common.go @@ -4,23 +4,34 @@ package setting import ( + "errors" "fmt" "net/http" "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) type OAuth2CommonHandlers struct { - OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID - 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 base.TplName // the template for the application edit page + Doer *user_model.User + Owner *user_model.User // nil for instance-wide, otherwise Org or User + 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 base.TplName // the template for the application edit page +} + +func (oa *OAuth2CommonHandlers) ownerID() int64 { + if oa.Owner != nil { + return oa.Owner.ID + } + return 0 } func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context) { @@ -50,7 +61,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 +70,8 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) { return } + audit.RecordOAuth2ApplicationAdd(ctx, oa.Doer, oa.Owner, app) + // render the edit page with secret ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true) ctx.Data["App"] = app @@ -82,7 +95,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("Application not found", nil) return } @@ -104,7 +117,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("Application not found", nil) return } @@ -114,18 +127,23 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) { return } - var err error - if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{ + app, 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 } + + ctx.Data["App"] = app + + audit.RecordOAuth2ApplicationUpdate(ctx, oa.Doer, oa.Owner, app) + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success")) ctx.Redirect(oa.BasePathList) } @@ -141,7 +159,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("Application not found", nil) return } @@ -151,28 +169,65 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) { ctx.ServerError("GenerateClientSecret", err) return } + + audit.RecordOAuth2ApplicationSecret(ctx, oa.Doer, oa.Owner, app) + ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true) oa.renderEditPage(ctx) } // 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 { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound("Application not found", err) + } else { + ctx.ServerError("GetOAuth2ApplicationByID", err) + } + return + } + + if err := auth.DeleteOAuth2Application(ctx, app.ID, oa.ownerID()); err != nil { ctx.ServerError("DeleteOAuth2Application", err) return } + audit.RecordOAuth2ApplicationRemove(ctx, oa.Doer, oa.Owner, app) + 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 + } + if grant == nil { + ctx.NotFound("Grant not found", nil) + return + } + + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound("Application not found", err) + } else { + ctx.ServerError("GetOAuth2ApplicationByID", err) + } + return + } + + if err := auth.RevokeOAuth2Grant(ctx, grant.ID, oa.ownerID()); err != nil { ctx.ServerError("RevokeOAuth2Grant", err) return } + audit.RecordUserOAuth2ApplicationRevoke(ctx, oa.Doer, oa.Owner, app, grant) + ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success")) ctx.JSONRedirect(oa.BasePathList) } diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 3b051c9b5f4b7..396f4d27bdd86 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -74,7 +74,7 @@ func ProfilePost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings") return } - if err := user_service.RenameUser(ctx, ctx.Doer, form.Name); err != nil { + if err := user_service.RenameUser(ctx, ctx.Doer, ctx.Doer, form.Name); err != nil { switch { case user_model.IsErrUserIsNotLocal(err): ctx.Flash.Error(ctx.Tr("form.username_change_not_local_user")) @@ -113,12 +113,11 @@ func ProfilePost(ctx *context.Context) { opts.FullName = optional.Some(form.FullName) } - if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, ctx.Doer, opts); err != nil { ctx.ServerError("UpdateUser", err) return } - log.Trace("User settings updated: %s", ctx.Doer.Name) ctx.Flash.Success(ctx.Tr("settings.update_profile_success")) ctx.Redirect(setting.AppSubURL + "/user/settings") } @@ -383,7 +382,7 @@ func UpdateUIThemePost(ctx *context.Context) { opts := &user_service.UpdateOptions{ Theme: optional.Some(form.Theme), } - if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, ctx.Doer, opts); err != nil { ctx.Flash.Error(ctx.Tr("settings.theme_update_error")) } else { ctx.Flash.Success(ctx.Tr("settings.theme_update_success")) @@ -409,7 +408,7 @@ func UpdateUserLang(ctx *context.Context) { opts := &user_service.UpdateOptions{ Language: optional.Some(form.Language), } - if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + if err := user_service.UpdateUser(ctx, ctx.Doer, ctx.Doer, opts); err != nil { ctx.ServerError("UpdateUser", err) return } diff --git a/routers/web/user/setting/security/2fa.go b/routers/web/user/setting/security/2fa.go index 7bb10248e8b7a..1b3ffb621f010 100644 --- a/routers/web/user/setting/security/2fa.go +++ b/routers/web/user/setting/security/2fa.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -56,6 +57,8 @@ func RegenerateScratchTwoFactor(ctx *context.Context) { return } + audit.RecordUserTwoFactorRegenerate(ctx, ctx.Doer, ctx.Doer, t) + ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token)) ctx.Redirect(setting.AppSubURL + "/user/settings/security") } @@ -92,6 +95,8 @@ func DisableTwoFactor(ctx *context.Context) { return } + audit.RecordUserTwoFactorDisable(ctx, ctx.Doer, ctx.Doer, t) + ctx.Flash.Success(ctx.Tr("settings.twofa_disabled")) ctx.Redirect(setting.AppSubURL + "/user/settings/security") } @@ -268,6 +273,8 @@ func EnrollTwoFactorPost(ctx *context.Context) { return } + audit.RecordUserTwoFactorEnable(ctx, ctx.Doer, 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 30eb6f63f89a0..986fa3db9da50 100644 --- a/routers/web/user/setting/security/openid.go +++ b/routers/web/user/setting/security/openid.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" ) @@ -102,6 +103,9 @@ func settingsOpenIDVerify(ctx *context.Context) { ctx.ServerError("AddUserOpenID", err) return } + + audit.RecordUserOpenIDAdd(ctx, ctx.Doer, ctx.Doer, oid) + log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name) ctx.Flash.Success(ctx.Tr("settings.add_openid_success")) @@ -115,10 +119,19 @@ 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.GetUserOpenID(ctx, ctx.FormInt64("id"), ctx.Doer.ID) + if err != nil { + ctx.ServerError("GetUserOpenID", err) + return + } + + if err := user_model.DeleteUserOpenID(ctx, oid); err != nil { ctx.ServerError("DeleteUserOpenID", err) return } + + audit.RecordUserOpenIDRemove(ctx, ctx.Doer, ctx.Doer, oid) + 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 b44cb4dd499fa..e43577b4fe85a 100644 --- a/routers/web/user/setting/security/security.go +++ b/routers/web/user/setting/security/security.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/context" ) @@ -51,17 +52,24 @@ func DeleteAccountLink(ctx *context.Context) { return } - id := ctx.FormInt64("id") - if id <= 0 { - ctx.Flash.Error("Account link id is not given") - } else { - if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil { - ctx.Flash.Error("RemoveAccountLink: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + user := &user_model.ExternalLoginUser{UserID: ctx.Doer.ID, LoginSourceID: ctx.FormInt64("id")} + if has, err := user_model.GetExternalLogin(ctx, user); err != nil || !has { + if !has { + err = user_model.ErrExternalLoginUserNotExist{UserID: user.UserID, LoginSourceID: user.LoginSourceID} } + ctx.ServerError("RemoveAccountLink", err) + return + } + + if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, user.LoginSourceID); err != nil { + ctx.ServerError("RemoveAccountLink", err) + return } + audit.RecordUserExternalLoginRemove(ctx, ctx.Doer, ctx.Doer, user) + + ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success")) + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") } diff --git a/routers/web/user/setting/security/webauthn.go b/routers/web/user/setting/security/webauthn.go index 70bfaac6e0943..c2fc89d004f40 100644 --- a/routers/web/user/setting/security/webauthn.go +++ b/routers/web/user/setting/security/webauthn.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -114,13 +115,15 @@ 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") + audit.RecordUserWebAuthAdd(ctx, ctx.Doer, ctx.Doer, dbCred) + ctx.JSON(http.StatusCreated, cred) } @@ -132,9 +135,19 @@ func WebauthnDelete(ctx *context.Context) { } form := web.GetForm(ctx).(*forms.WebauthnDeleteForm) - if _, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { + + cred, err := auth.GetWebAuthnCredentialByID(ctx, form.ID) + if err != nil { ctx.ServerError("GetWebAuthnCredentialByID", err) return } + + if ok, err := auth.DeleteCredential(ctx, form.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("DeleteCredential", err) + return + } else if ok { + audit.RecordUserWebAuthRemove(ctx, ctx.Doer, ctx.Doer, cred) + } + ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security") } diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go index 3732ca27c06f1..e601c04342039 100644 --- a/routers/web/user/setting/webhooks.go +++ b/routers/web/user/setting/webhooks.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/context" ) @@ -39,9 +40,17 @@ 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.ServerError("GetWebhookByOwnerID", err) + return + } + + if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, hook.ID); err != nil { ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { + audit.RecordWebhookRemove(ctx, ctx.Doer, ctx.Doer, nil, hook) + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/web.go b/routers/web/web.go index 5ed046a9838b1..3dbabea3fbff8 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -389,6 +389,13 @@ func registerRoutes(m *web.Router) { } } + auditLogsEnabled := func(ctx *context.Context) { + if !setting.Audit.Enabled { + ctx.Error(http.StatusNotFound) + return + } + } + reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode, ignoreGlobal bool) func(ctx *context.Context) { return func(ctx *context.Context) { // only check global disabled units when ignoreGlobal is false @@ -669,6 +676,8 @@ func registerRoutes(m *web.Router) { addWebhookEditRoutes() }, webhooksEnabled) + m.Get("/audit_logs", auditLogsEnabled, user_setting.ViewAuditLogs) + m.Group("/blocked_users", func() { m.Get("", user_setting.BlockedUsers) m.Post("", web.Bind(forms.BlockUserForm{}), user_setting.BlockedUsersPost) @@ -716,6 +725,7 @@ func registerRoutes(m *web.Router) { }) m.Group("/monitor", func() { + m.Get("/audit_logs", auditLogsEnabled, admin.ViewAuditLogs) m.Get("/stats", admin.MonitorStats) m.Get("/cron", admin.CronTasks) m.Get("/stacktrace", admin.Stacktrace) @@ -949,6 +959,8 @@ func registerRoutes(m *web.Router) { addSettingsVariablesRoutes() }, actions.MustEnableActions) + m.Get("/audit_logs", auditLogsEnabled, org_setting.ViewAuditLogs) + m.Methods("GET,POST", "/delete", org.SettingsDelete) m.Group("/packages", func() { @@ -1135,6 +1147,7 @@ func registerRoutes(m *web.Router) { addSettingsSecretsRoutes() addSettingsVariablesRoutes() }, actions.MustEnableActions) + m.Get("/audit_logs", auditLogsEnabled, 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/asymkey/deploy_key.go b/services/asymkey/deploy_key.go index 324688c53408e..18c19eba73401 100644 --- a/services/asymkey/deploy_key.go +++ b/services/asymkey/deploy_key.go @@ -5,10 +5,16 @@ package asymkey import ( "context" + "errors" + "fmt" "code.gitea.io/gitea/models" + asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/audit" ) // DeleteDeployKey deletes deploy key from its repository authorized_keys file if needed. @@ -19,6 +25,16 @@ func DeleteDeployKey(ctx context.Context, doer *user_model.User, id int64) error } defer committer.Close() + key, err := asymkey_model.GetDeployKeyByID(dbCtx, id) + if err != nil && !errors.Is(err, util.ErrNotExist) { + return fmt.Errorf("GetDeployKeyByID: %w", err) + } + + repo, err := repo_model.GetRepositoryByID(dbCtx, key.RepoID) + if err != nil { + return fmt.Errorf("GetRepositoryByID: %w", err) + } + if err := models.DeleteDeployKey(dbCtx, doer, id); err != nil { return err } @@ -26,5 +42,7 @@ func DeleteDeployKey(ctx context.Context, doer *user_model.User, id int64) error return err } + audit.RecordRepositoryDeployKeyRemove(ctx, doer, repo, key) + return RewriteAllPublicKeys(ctx) } diff --git a/services/asymkey/ssh_key.go b/services/asymkey/ssh_key.go index da57059d4b40f..332da494fa7f4 100644 --- a/services/asymkey/ssh_key.go +++ b/services/asymkey/ssh_key.go @@ -9,6 +9,7 @@ import ( asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" ) // DeletePublicKey deletes SSH key information both in database and authorized_keys file. @@ -18,6 +19,11 @@ func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err return err } + owner, err := user_model.GetUserByID(db.DefaultContext, key.OwnerID) + if err != nil { + return err + } + // Check if user has access to delete this key. if !doer.IsAdmin && doer.ID != key.OwnerID { return asymkey_model.ErrKeyAccessDenied{ @@ -43,8 +49,12 @@ func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err committer.Close() if key.Type == asymkey_model.KeyTypePrincipal { + audit.RecordUserKeyPrincipalRemove(ctx, doer, owner, key) + return RewriteAllPrincipalKeys(ctx) } + audit.RecordUserKeySSHRemove(ctx, doer, owner, key) + return RewriteAllPublicKeys(ctx) } diff --git a/services/audit/audit.go b/services/audit/audit.go new file mode 100644 index 0000000000000..7b03be288448e --- /dev/null +++ b/services/audit/audit.go @@ -0,0 +1,153 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "fmt" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + audit_model "code.gitea.io/gitea/models/audit" + auth_model "code.gitea.io/gitea/models/auth" + git_model "code.gitea.io/gitea/models/git" + organization_model "code.gitea.io/gitea/models/organization" + repository_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/setting" +) + +type TypeDescriptor struct { + Type audit_model.ObjectType `json:"type"` + ID int64 `json:"id"` + Object any `json:"-"` +} + +func (d TypeDescriptor) DisplayName() string { + switch t := d.Object.(type) { + case *repository_model.Repository: + return t.FullName() + case *user_model.User: + return t.Name + case *organization_model.Organization: + return t.Name + case *user_model.EmailAddress: + return t.Email + case *organization_model.Team: + return t.Name + case *auth_model.WebAuthnCredential: + return t.Name + case *user_model.UserOpenID: + return t.URI + case *auth_model.AccessToken: + return t.Name + case *auth_model.OAuth2Application: + return t.Name + case *auth_model.Source: + return t.Name + case *asymkey_model.PublicKey: + return t.Fingerprint + case *asymkey_model.GPGKey: + return t.KeyID + case *secret_model.Secret: + return t.Name + case *webhook_model.Webhook: + return t.URL + case *git_model.ProtectedTag: + return t.NamePattern + case *git_model.ProtectedBranch: + return t.RuleName + case *repository_model.PushMirror: + return t.RemoteAddress + } + + if d.Type == audit_model.TypeSystem { + return "System" + } + + return "" +} + +func (d TypeDescriptor) HTMLURL() string { + switch t := d.Object.(type) { + case *repository_model.Repository: + return t.HTMLURL() + case *user_model.User: + return t.HTMLURL() + case *organization_model.Organization: + return t.HTMLURL() + } + return "" +} + +func Init() error { + if !setting.Audit.Enabled { + return nil + } + + return initAuditFile() +} + +var systemObject struct{} + +func scopeToDescription(scope any) TypeDescriptor { + if scope == &systemObject { + return TypeDescriptor{audit_model.TypeSystem, 0, nil} + } + + switch s := scope.(type) { + case *repository_model.Repository, *user_model.User, *organization_model.Organization: + return typeToDescription(scope) + default: + panic(fmt.Sprintf("unsupported scope type: %T", s)) + } +} + +func typeToDescription(val any) TypeDescriptor { + if val == &systemObject { + return TypeDescriptor{audit_model.TypeSystem, 0, nil} + } + + switch t := val.(type) { + case *repository_model.Repository: + return TypeDescriptor{audit_model.TypeRepository, t.ID, val} + case *user_model.User: + if t.IsOrganization() { + return TypeDescriptor{audit_model.TypeOrganization, t.ID, val} + } + return TypeDescriptor{audit_model.TypeUser, t.ID, val} + case *organization_model.Organization: + return TypeDescriptor{audit_model.TypeOrganization, t.ID, val} + case *user_model.EmailAddress: + return TypeDescriptor{audit_model.TypeEmailAddress, t.ID, val} + case *organization_model.Team: + return TypeDescriptor{audit_model.TypeTeam, t.ID, val} + case *auth_model.WebAuthnCredential: + return TypeDescriptor{audit_model.TypeWebAuthnCredential, t.ID, val} + case *user_model.UserOpenID: + return TypeDescriptor{audit_model.TypeOpenID, t.ID, val} + case *auth_model.AccessToken: + return TypeDescriptor{audit_model.TypeAccessToken, t.ID, val} + case *auth_model.OAuth2Application: + return TypeDescriptor{audit_model.TypeOAuth2Application, t.ID, val} + case *auth_model.Source: + return TypeDescriptor{audit_model.TypeAuthenticationSource, t.ID, val} + case *asymkey_model.PublicKey: + return TypeDescriptor{audit_model.TypePublicKey, t.ID, val} + case *asymkey_model.GPGKey: + return TypeDescriptor{audit_model.TypeGPGKey, t.ID, val} + case *secret_model.Secret: + return TypeDescriptor{audit_model.TypeSecret, t.ID, val} + case *webhook_model.Webhook: + return TypeDescriptor{audit_model.TypeWebhook, t.ID, val} + case *git_model.ProtectedTag: + return TypeDescriptor{audit_model.TypeProtectedTag, t.ID, val} + case *git_model.ProtectedBranch: + return TypeDescriptor{audit_model.TypeProtectedBranch, t.ID, val} + case *repository_model.PushMirror: + return TypeDescriptor{audit_model.TypePushMirror, t.ID, val} + default: + panic(fmt.Sprintf("unsupported type: %T", t)) + } +} diff --git a/services/audit/audit_test.go b/services/audit/audit_test.go new file mode 100644 index 0000000000000..aa9e3b9b2d5a4 --- /dev/null +++ b/services/audit/audit_test.go @@ -0,0 +1,307 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + "net/http" + "testing" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + audit_model "code.gitea.io/gitea/models/audit" + auth_model "code.gitea.io/gitea/models/auth" + git_model "code.gitea.io/gitea/models/git" + organization_model "code.gitea.io/gitea/models/organization" + repository_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" +) + +func TestBuildEvent(t *testing.T) { + equal := func(expected, e *Event) { + expected.Time = time.Time{} + e.Time = time.Time{} + + assert.Equal(t, expected, e) + } + + ctx := context.Background() + + u := &user_model.User{ID: 1, Name: "TestUser"} + r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"} + m := &repository_model.PushMirror{ID: 4} + doer := &user_model.User{ID: 2, Name: "Doer"} + + equal( + &Event{ + Action: audit_model.UserCreate, + Actor: TypeDescriptor{Type: "user", ID: 2, Object: doer}, + Scope: TypeDescriptor{Type: "user", ID: 1, Object: u}, + Target: TypeDescriptor{Type: "user", ID: 1, Object: u}, + Message: "Created user TestUser.", + }, + buildEvent( + ctx, + audit_model.UserCreate, + doer, + u, + u, + "Created user %s.", + u.Name, + ), + ) + equal( + &Event{ + Action: audit_model.RepositoryMirrorPushAdd, + Actor: TypeDescriptor{Type: "user", ID: 2, Object: doer}, + Scope: TypeDescriptor{Type: "repository", ID: 3, Object: r}, + Target: TypeDescriptor{Type: "push_mirror", ID: 4, Object: m}, + Message: "Added push mirror for repository TestUser/TestRepo.", + }, + buildEvent( + ctx, + audit_model.RepositoryMirrorPushAdd, + doer, + r, + m, + "Added push mirror for repository %s.", + r.FullName(), + ), + ) + + e := buildEvent(ctx, audit_model.UserCreate, doer, u, u, "") + assert.Empty(t, e.IPAddress) + + ctx = context.WithValue(ctx, httplib.RequestContextKey, &http.Request{RemoteAddr: "127.0.0.1:1234"}) + + e = buildEvent(ctx, audit_model.UserCreate, doer, u, u, "") + assert.Equal(t, "127.0.0.1", e.IPAddress) +} + +func TestScopeToDescription(t *testing.T) { + cases := []struct { + ShouldPanic bool + Scope any + Expected TypeDescriptor + }{ + { + Scope: nil, + ShouldPanic: true, + }, + { + Scope: &systemObject, + Expected: TypeDescriptor{Type: audit_model.TypeSystem, ID: 0}, + }, + { + Scope: &user_model.User{ID: 1, Name: "TestUser"}, + Expected: TypeDescriptor{Type: audit_model.TypeUser, ID: 1}, + }, + { + Scope: &organization_model.Organization{ID: 2, Name: "TestOrg"}, + Expected: TypeDescriptor{Type: audit_model.TypeOrganization, ID: 2}, + }, + { + Scope: &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}, + Expected: TypeDescriptor{Type: audit_model.TypeRepository, ID: 3}, + }, + { + ShouldPanic: true, + Scope: &organization_model.Team{ID: 345, Name: "Team"}, + }, + { + ShouldPanic: true, + Scope: 1234, + }, + } + for _, c := range cases { + if c.Scope != &systemObject { + c.Expected.Object = c.Scope + } + + if c.ShouldPanic { + assert.Panics(t, func() { + _ = scopeToDescription(c.Scope) + }) + } else { + assert.Equal(t, c.Expected, scopeToDescription(c.Scope), "Unexpected descriptor for scope: %T", c.Scope) + } + } +} + +func TestTypeToDescription(t *testing.T) { + setting.AppURL = "http://localhost:3000/" + + type Expected struct { + TypeDescriptor TypeDescriptor + DisplayName string + HTMLURL string + } + + cases := []struct { + ShouldPanic bool + Type any + Expected Expected + }{ + { + Type: nil, + ShouldPanic: true, + }, + { + Type: &systemObject, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeSystem, ID: 0}, + DisplayName: "System", + }, + }, + { + Type: &user_model.User{ID: 1, Name: "TestUser"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeUser, ID: 1}, + DisplayName: "TestUser", + HTMLURL: "http://localhost:3000/TestUser", + }, + }, + { + Type: &organization_model.Organization{ID: 2, Name: "TestOrg"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeOrganization, ID: 2}, + DisplayName: "TestOrg", + HTMLURL: "http://localhost:3000/TestOrg", + }, + }, + { + Type: &user_model.EmailAddress{ID: 3, Email: "user@gitea.com"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeEmailAddress, ID: 3}, + DisplayName: "user@gitea.com", + }, + }, + { + Type: &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeRepository, ID: 3}, + DisplayName: "TestUser/TestRepo", + HTMLURL: "http://localhost:3000/TestUser/TestRepo", + }, + }, + { + Type: &organization_model.Team{ID: 4, Name: "TestTeam"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeTeam, ID: 4}, + DisplayName: "TestTeam", + }, + }, + { + Type: &auth_model.WebAuthnCredential{ID: 6, Name: "TestCredential"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeWebAuthnCredential, ID: 6}, + DisplayName: "TestCredential", + }, + }, + { + Type: &user_model.UserOpenID{ID: 7, URI: "test://uri"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeOpenID, ID: 7}, + DisplayName: "test://uri", + }, + }, + { + Type: &auth_model.AccessToken{ID: 8, Name: "TestToken"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeAccessToken, ID: 8}, + DisplayName: "TestToken", + }, + }, + { + Type: &auth_model.OAuth2Application{ID: 9, Name: "TestOAuth2Application"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeOAuth2Application, ID: 9}, + DisplayName: "TestOAuth2Application", + }, + }, + { + Type: &auth_model.Source{ID: 11, Name: "TestSource"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeAuthenticationSource, ID: 11}, + DisplayName: "TestSource", + }, + }, + { + Type: &asymkey_model.PublicKey{ID: 13, Fingerprint: "TestPublicKey"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypePublicKey, ID: 13}, + DisplayName: "TestPublicKey", + }, + }, + { + Type: &asymkey_model.GPGKey{ID: 14, KeyID: "TestGPGKey"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeGPGKey, ID: 14}, + DisplayName: "TestGPGKey", + }, + }, + { + Type: &secret_model.Secret{ID: 15, Name: "TestSecret"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeSecret, ID: 15}, + DisplayName: "TestSecret", + }, + }, + { + Type: &webhook_model.Webhook{ID: 16, URL: "test://webhook"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeWebhook, ID: 16}, + DisplayName: "test://webhook", + }, + }, + { + Type: &git_model.ProtectedTag{ID: 17, NamePattern: "TestProtectedTag"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeProtectedTag, ID: 17}, + DisplayName: "TestProtectedTag", + }, + }, + { + Type: &git_model.ProtectedBranch{ID: 18, RuleName: "TestProtectedBranch"}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypeProtectedBranch, ID: 18}, + DisplayName: "TestProtectedBranch", + }, + }, + { + Type: &repository_model.PushMirror{ID: 19}, + Expected: Expected{ + TypeDescriptor: TypeDescriptor{Type: audit_model.TypePushMirror, ID: 19}, + DisplayName: "", + }, + }, + { + ShouldPanic: true, + Type: 1234, + }, + } + for _, c := range cases { + if c.Type != &systemObject { + c.Expected.TypeDescriptor.Object = c.Type + } + + if c.ShouldPanic { + assert.Panics(t, func() { + _ = typeToDescription(c.Type) + }) + } else { + d := typeToDescription(c.Type) + + assert.Equal(t, c.Expected.TypeDescriptor, d, "Unexpected descriptor for type: %T", c.Type) + assert.Equal(t, c.Expected.DisplayName, d.DisplayName(), "Unexpected display name for type: %T", c.Type) + assert.Equal(t, c.Expected.HTMLURL, d.HTMLURL(), "Unexpected url for type: %T", c.Type) + } + } +} diff --git a/services/audit/database.go b/services/audit/database.go new file mode 100644 index 0000000000000..e8a4cbd27968e --- /dev/null +++ b/services/audit/database.go @@ -0,0 +1,26 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + + audit_model "code.gitea.io/gitea/models/audit" + "code.gitea.io/gitea/modules/timeutil" +) + +func writeToDatabase(ctx context.Context, e *Event) error { + _, err := audit_model.InsertEvent(ctx, &audit_model.Event{ + Action: e.Action, + ActorID: e.Actor.ID, + ScopeType: e.Scope.Type, + ScopeID: e.Scope.ID, + TargetType: e.Target.Type, + TargetID: e.Target.ID, + Message: e.Message, + IPAddress: e.IPAddress, + TimestampUnix: timeutil.TimeStamp(e.Time.Unix()), + }) + return err +} diff --git a/services/audit/display.go b/services/audit/display.go new file mode 100644 index 0000000000000..03cafbd7c5a26 --- /dev/null +++ b/services/audit/display.go @@ -0,0 +1,134 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + "fmt" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + audit_model "code.gitea.io/gitea/models/audit" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + organization_model "code.gitea.io/gitea/models/organization" + repository_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" +) + +type cache = map[audit_model.ObjectType]map[int64]TypeDescriptor + +func FindEvents(ctx context.Context, opts *audit_model.EventSearchOptions) ([]*Event, int64, error) { + events, total, err := audit_model.FindEvents(ctx, opts) + if err != nil { + return nil, 0, err + } + + return fromDatabaseEvents(ctx, events), total, nil +} + +func fromDatabaseEvents(ctx context.Context, evs []*audit_model.Event) []*Event { + c := cache{} + + users := make(map[int64]TypeDescriptor) + for _, systemUser := range []*user_model.User{ + user_model.NewGhostUser(), + user_model.NewActionsUser(), + user_model.NewCLIUser(), + user_model.NewAuthenticationSourceUser(), + } { + users[systemUser.ID] = typeToDescription(systemUser) + } + c[audit_model.TypeUser] = users + + events := make([]*Event, 0, len(evs)) + for _, e := range evs { + events = append(events, fromDatabaseEvent(ctx, e, c)) + } + return events +} + +func fromDatabaseEvent(ctx context.Context, e *audit_model.Event, c cache) *Event { + return &Event{ + Action: e.Action, + Actor: resolveType(ctx, audit_model.TypeUser, e.ActorID, c), + Scope: resolveType(ctx, e.ScopeType, e.ScopeID, c), + Target: resolveType(ctx, e.TargetType, e.TargetID, c), + Message: e.Message, + Time: e.TimestampUnix.AsTime(), + IPAddress: e.IPAddress, + } +} + +func resolveType(ctx context.Context, t audit_model.ObjectType, id int64, c cache) TypeDescriptor { + oc, has := c[t] + if !has { + oc = make(map[int64]TypeDescriptor) + c[t] = oc + } + + td, has := oc[id] + if has { + return td + } + + switch t { + case audit_model.TypeSystem: + td, has = typeToDescription(&systemObject), true + case audit_model.TypeRepository: + td, has = getTypeDescriptorByID[repository_model.Repository](ctx, id) + case audit_model.TypeUser: + td, has = getTypeDescriptorByID[user_model.User](ctx, id) + case audit_model.TypeOrganization: + td, has = getTypeDescriptorByID[organization_model.Organization](ctx, id) + case audit_model.TypeEmailAddress: + td, has = getTypeDescriptorByID[user_model.EmailAddress](ctx, id) + case audit_model.TypeTeam: + td, has = getTypeDescriptorByID[organization_model.Team](ctx, id) + case audit_model.TypeWebAuthnCredential: + td, has = getTypeDescriptorByID[auth_model.WebAuthnCredential](ctx, id) + case audit_model.TypeOpenID: + td, has = getTypeDescriptorByID[user_model.UserOpenID](ctx, id) + case audit_model.TypeAccessToken: + td, has = getTypeDescriptorByID[auth_model.AccessToken](ctx, id) + case audit_model.TypeOAuth2Application: + td, has = getTypeDescriptorByID[auth_model.OAuth2Application](ctx, id) + case audit_model.TypeAuthenticationSource: + td, has = getTypeDescriptorByID[auth_model.Source](ctx, id) + case audit_model.TypePublicKey: + td, has = getTypeDescriptorByID[asymkey_model.PublicKey](ctx, id) + case audit_model.TypeGPGKey: + td, has = getTypeDescriptorByID[asymkey_model.GPGKey](ctx, id) + case audit_model.TypeSecret: + td, has = getTypeDescriptorByID[secret_model.Secret](ctx, id) + case audit_model.TypeWebhook: + td, has = getTypeDescriptorByID[webhook_model.Webhook](ctx, id) + case audit_model.TypeProtectedTag: + td, has = getTypeDescriptorByID[git_model.ProtectedTag](ctx, id) + case audit_model.TypeProtectedBranch: + td, has = getTypeDescriptorByID[git_model.ProtectedBranch](ctx, id) + case audit_model.TypePushMirror: + td, has = getTypeDescriptorByID[repository_model.PushMirror](ctx, id) + default: + panic(fmt.Sprintf("unsupported type: %v", t)) + } + + if !has { + td = TypeDescriptor{t, id, nil} + } + + oc[id] = td + + return td +} + +func getTypeDescriptorByID[T any](ctx context.Context, id int64) (TypeDescriptor, bool) { + if bean, has, _ := db.GetByID[T](ctx, id); has { + return typeToDescription(bean), true + } + + return TypeDescriptor{}, false +} diff --git a/services/audit/file.go b/services/audit/file.go new file mode 100644 index 0000000000000..70a236921943b --- /dev/null +++ b/services/audit/file.go @@ -0,0 +1,59 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "io" + + audit_model "code.gitea.io/gitea/models/audit" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util/rotatingfilewriter" +) + +var rfw *rotatingfilewriter.RotatingFileWriter + +func initAuditFile() error { + if setting.Audit.FileOptions == nil { + return nil + } + + opts := setting.Audit.FileOptions + + var err error + rfw, err = rotatingfilewriter.Open(opts.FileName, &rotatingfilewriter.Options{ + Rotate: opts.LogRotate, + MaximumSize: opts.MaxSize, + RotateDaily: opts.DailyRotate, + KeepDays: opts.MaxDays, + Compress: opts.Compress, + CompressionLevel: opts.CompressionLevel, + }) + return err +} + +func writeToFile(e *Event) error { + if rfw == nil { + return nil + } + return WriteEventAsJSON(rfw, e) +} + +func (d TypeDescriptor) MarshalJSON() ([]byte, error) { + type out struct { + Type audit_model.ObjectType `json:"type"` + ID int64 `json:"id"` + DisplayName string `json:"display_name"` + } + + return json.Marshal(out{ + Type: d.Type, + ID: d.ID, + DisplayName: d.DisplayName(), + }) +} + +func WriteEventAsJSON(w io.Writer, e *Event) error { + return json.NewEncoder(w).Encode(e) +} diff --git a/services/audit/file_test.go b/services/audit/file_test.go new file mode 100644 index 0000000000000..07c85820edc7d --- /dev/null +++ b/services/audit/file_test.go @@ -0,0 +1,46 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + audit_model "code.gitea.io/gitea/models/audit" + repository_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/httplib" + + "github.com/stretchr/testify/assert" +) + +func TestWriteEventAsJSON(t *testing.T) { + r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"} + m := &repository_model.PushMirror{ID: 4} + doer := &user_model.User{ID: 2, Name: "Doer"} + + ctx := context.WithValue(context.Background(), httplib.RequestContextKey, &http.Request{RemoteAddr: "127.0.0.1:1234"}) + + e := buildEvent( + ctx, + audit_model.RepositoryMirrorPushAdd, + doer, + r, + m, + "Added push mirror for repository %s.", + r.FullName(), + ) + e.Time = time.Time{} + + sb := strings.Builder{} + assert.NoError(t, WriteEventAsJSON(&sb, e)) + assert.Equal( + t, + `{"action":"repository:mirror:push:add","actor":{"type":"user","id":2,"display_name":"Doer"},"scope":{"type":"repository","id":3,"display_name":"TestUser/TestRepo"},"target":{"type":"push_mirror","id":4,"display_name":""},"message":"Added push mirror for repository TestUser/TestRepo.","time":"0001-01-01T00:00:00Z","ip_address":"127.0.0.1"}`+"\n", + sb.String(), + ) +} diff --git a/services/audit/record.go b/services/audit/record.go new file mode 100644 index 0000000000000..1a303f2e4a5ac --- /dev/null +++ b/services/audit/record.go @@ -0,0 +1,513 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package audit + +import ( + "context" + "fmt" + "time" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + audit_model "code.gitea.io/gitea/models/audit" + auth_model "code.gitea.io/gitea/models/auth" + git_model "code.gitea.io/gitea/models/git" + organization_model "code.gitea.io/gitea/models/organization" + perm_model "code.gitea.io/gitea/models/perm" + repository_model "code.gitea.io/gitea/models/repo" + secret_model "code.gitea.io/gitea/models/secret" + user_model "code.gitea.io/gitea/models/user" + webhook_model "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" +) + +type Event struct { + Action audit_model.Action `json:"action"` + Actor TypeDescriptor `json:"actor"` + Scope TypeDescriptor `json:"scope"` + Target TypeDescriptor `json:"target"` + Message string `json:"message"` + Time time.Time `json:"time"` + IPAddress string `json:"ip_address"` +} + +func buildEvent(ctx context.Context, action audit_model.Action, actor *user_model.User, scope, target any, message string, v ...any) *Event { + return &Event{ + Action: action, + Actor: typeToDescription(actor), + Scope: scopeToDescription(scope), + Target: typeToDescription(target), + Message: fmt.Sprintf(message, v...), + Time: time.Now(), + IPAddress: httplib.TryGetIPAddress(ctx), + } +} + +func record(ctx context.Context, action audit_model.Action, actor *user_model.User, scope, target any, message string, v ...any) { + if !setting.Audit.Enabled { + return + } + + e := buildEvent(ctx, action, actor, scope, target, message, v...) + + if err := writeToFile(e); err != nil { + log.Error("Error writing audit event to file: %v", err) + } + if err := writeToDatabase(ctx, e); err != nil { + log.Error("Error writing audit event %+v to database: %v", e, err) + } +} + +func RecordUserImpersonation(ctx context.Context, impersonator, target *user_model.User) { + record(ctx, audit_model.UserImpersonation, impersonator, impersonator, target, "User %s impersonating user %s.", impersonator.Name, target.Name) +} + +func RecordUserCreate(ctx context.Context, doer, user *user_model.User) { + if user.IsOrganization() { + record(ctx, audit_model.OrganizationCreate, doer, user, user, "Created organization %s.", user.Name) + } else { + record(ctx, audit_model.UserCreate, doer, user, user, "Created user %s.", user.Name) + } +} + +func RecordUserDelete(ctx context.Context, doer, user *user_model.User) { + if user.IsOrganization() { + record(ctx, audit_model.OrganizationDelete, doer, user, user, "Deleted organization %s.", user.Name) + } else { + record(ctx, audit_model.UserDelete, doer, user, user, "Deleted user %s.", user.Name) + } +} + +func RecordUserAuthenticationFailTwoFactor(ctx context.Context, user *user_model.User) { + record(ctx, audit_model.UserAuthenticationFailTwoFactor, user, user, user, "Failed two-factor authentication for user %s.", user.Name) +} + +func RecordUserAuthenticationSource(ctx context.Context, doer, user *user_model.User) { + record(ctx, audit_model.UserAuthenticationSource, doer, user, user, "Changed authentication source of user %s.", user.Name) +} + +func RecordUserActive(ctx context.Context, doer, user *user_model.User) { + status := "active" + if !user.IsActive { + status = "inactive" + } + + record(ctx, audit_model.UserActive, doer, user, user, "Changed activation status of user %s to %s.", user.Name, status) +} + +func RecordUserRestricted(ctx context.Context, doer, user *user_model.User) { + status := "restricted" + if !user.IsRestricted { + status = "unrestricted" + } + + record(ctx, audit_model.UserRestricted, doer, user, user, "Changed restricted status of user %s to %s.", user.Name, status) +} + +func RecordUserAdmin(ctx context.Context, doer, user *user_model.User) { + status := "admin" + if !user.IsAdmin { + status = "normal user" + } + + record(ctx, audit_model.UserAdmin, doer, user, user, "Changed admin status of user %s to %s.", user.Name, status) +} + +func RecordUserName(ctx context.Context, doer, user *user_model.User) { + if user.IsOrganization() { + record(ctx, audit_model.OrganizationName, doer, user, user, "Changed organization name to %s.", user.Name) + } else { + record(ctx, audit_model.UserName, doer, user, user, "Changed user name to %s.", user.Name) + } +} + +func RecordUserPassword(ctx context.Context, doer, user *user_model.User) { + record(ctx, audit_model.UserPassword, doer, user, user, "Changed password of user %s.", user.Name) +} + +func RecordUserPasswordResetRequest(ctx context.Context, doer, user *user_model.User) { + record(ctx, audit_model.UserPasswordResetRequest, doer, user, user, "Requested password reset for user %s.", user.Name) +} + +func RecordUserVisibility(ctx context.Context, doer, user *user_model.User) { + if user.IsOrganization() { + record(ctx, audit_model.OrganizationVisibility, doer, user, user, "Changed visibility of organization %s to %s.", user.Name, user.Visibility.String()) + } else { + record(ctx, audit_model.UserVisibility, doer, user, user, "Changed visibility of user %s to %s.", user.Name, user.Visibility.String()) + } +} + +func RecordUserEmailPrimaryChange(ctx context.Context, doer, user *user_model.User, email *user_model.EmailAddress) { + record(ctx, audit_model.UserEmailPrimaryChange, doer, user, email, "Changed primary email of user %s to %s.", user.Name, email.Email) +} + +func RecordUserEmailAdd(ctx context.Context, doer, user *user_model.User, email *user_model.EmailAddress) { + record(ctx, audit_model.UserEmailAdd, doer, user, email, "Added email %s to user %s.", email.Email, user.Name) +} + +func RecordUserEmailActivate(ctx context.Context, doer, user *user_model.User, email *user_model.EmailAddress) { + status := "active" + if !email.IsActivated { + status = "inactive" + } + + record(ctx, audit_model.UserEmailActivate, doer, user, email, "Changed activation status of email %s of user %s to %s.", email.Email, user.Name, status) +} + +func RecordUserEmailRemove(ctx context.Context, doer, user *user_model.User, email *user_model.EmailAddress) { + record(ctx, audit_model.UserEmailRemove, doer, user, email, "Removed email %s from user %s.", email.Email, user.Name) +} + +func RecordUserTwoFactorEnable(ctx context.Context, doer, user *user_model.User) { + record(ctx, audit_model.UserTwoFactorEnable, doer, user, user, "Enabled two-factor authentication for user %s.", user.Name) +} + +func RecordUserTwoFactorRegenerate(ctx context.Context, doer, user *user_model.User, tf *auth_model.TwoFactor) { + record(ctx, audit_model.UserTwoFactorRegenerate, doer, user, tf, "Regenerated two-factor authentication secret for user %s.", user.Name) +} + +func RecordUserTwoFactorDisable(ctx context.Context, doer, user *user_model.User, tf *auth_model.TwoFactor) { + record(ctx, audit_model.UserTwoFactorDisable, doer, user, tf, "Disabled two-factor authentication for user %s.", user.Name) +} + +func RecordUserWebAuthAdd(ctx context.Context, doer, user *user_model.User, authn *auth_model.WebAuthnCredential) { + record(ctx, audit_model.UserWebAuthAdd, doer, user, authn, "Added WebAuthn key %s for user %s.", authn.Name, user.Name) +} + +func RecordUserWebAuthRemove(ctx context.Context, doer, user *user_model.User, authn *auth_model.WebAuthnCredential) { + record(ctx, audit_model.UserWebAuthRemove, doer, user, authn, "Removed WebAuthn key %s from user %s.", authn.Name, user.Name) +} + +func RecordUserExternalLoginAdd(ctx context.Context, doer, user *user_model.User, externalLogin *user_model.ExternalLoginUser) { + record(ctx, audit_model.UserExternalLoginAdd, doer, user, "Added external login %s for user %s using provider %s.", externalLogin.ExternalID, user.Name, externalLogin.Provider) +} + +func RecordUserExternalLoginRemove(ctx context.Context, doer, user *user_model.User, externalLogin *user_model.ExternalLoginUser) { + record(ctx, audit_model.UserExternalLoginRemove, doer, user, "Removed external login %s for user %s from provider.", externalLogin.ExternalID, user.Name, externalLogin.Provider) +} + +func RecordUserOpenIDAdd(ctx context.Context, doer, user *user_model.User, oid *user_model.UserOpenID) { + record(ctx, audit_model.UserOpenIDAdd, doer, user, oid, "Associated OpenID %s to user %s.", oid.URI, user.Name) +} + +func RecordUserOpenIDRemove(ctx context.Context, doer, user *user_model.User, oid *user_model.UserOpenID) { + record(ctx, audit_model.UserOpenIDRemove, doer, user, oid, "Removed OpenID %s from user %s.", oid.URI, user.Name) +} + +func RecordUserAccessTokenAdd(ctx context.Context, doer, user *user_model.User, token *auth_model.AccessToken) { + record(ctx, audit_model.UserAccessTokenAdd, doer, user, token, "Added access token %s for user %s with scope %s.", token.Name, user.Name, token.Scope) +} + +func RecordUserAccessTokenRemove(ctx context.Context, doer, user *user_model.User, token *auth_model.AccessToken) { + record(ctx, audit_model.UserAccessTokenRemove, doer, user, token, "Removed access token %s from user %s.", token.Name, user.Name) +} + +func RecordOAuth2ApplicationAdd(ctx context.Context, doer, user *user_model.User, app *auth_model.OAuth2Application) { + if user == nil { + record(ctx, audit_model.SystemOAuth2ApplicationAdd, doer, &systemObject, app, "Created instance-wide OAuth2 application %s", app.Name) + } else if user.IsOrganization() { + record(ctx, audit_model.OrganizationOAuth2ApplicationAdd, doer, user, app, "Created OAuth2 application %s for organization %s", app.Name, user.Name) + } else { + record(ctx, audit_model.UserOAuth2ApplicationAdd, doer, user, app, "Created OAuth2 application %s for user %s", app.Name, user.Name) + } +} + +func RecordOAuth2ApplicationUpdate(ctx context.Context, doer, user *user_model.User, app *auth_model.OAuth2Application) { + if user == nil { + record(ctx, audit_model.SystemOAuth2ApplicationUpdate, doer, &systemObject, app, "Updated instance-wide OAuth2 application %s", app.Name) + } else if user.IsOrganization() { + record(ctx, audit_model.OrganizationOAuth2ApplicationUpdate, doer, user, app, "Updated OAuth2 application %s of organization %s", app.Name, user.Name) + } else { + record(ctx, audit_model.UserOAuth2ApplicationUpdate, doer, user, app, "Updated OAuth2 application %s of user %s", app.Name, user.Name) + } +} + +func RecordOAuth2ApplicationSecret(ctx context.Context, doer, user *user_model.User, app *auth_model.OAuth2Application) { + if user == nil { + record(ctx, audit_model.SystemOAuth2ApplicationSecret, doer, &systemObject, app, "Regenerated secret for instance-wide OAuth2 application %s", app.Name) + } else if user.IsOrganization() { + record(ctx, audit_model.OrganizationOAuth2ApplicationSecret, doer, user, app, "Regenerated secret for OAuth2 application %s of organization %s", app.Name, user.Name) + } else { + record(ctx, audit_model.UserOAuth2ApplicationSecret, doer, user, app, "Regenerated secret for OAuth2 application %s of user %s", app.Name, user.Name) + } +} + +func RecordUserOAuth2ApplicationGrant(ctx context.Context, doer, owner *user_model.User, app *auth_model.OAuth2Application, grant *auth_model.OAuth2Grant) { + record(ctx, audit_model.UserOAuth2ApplicationGrant, doer, owner, grant, "Granted OAuth2 access to application %s of user %s.", app.Name, owner.Name) +} + +func RecordUserOAuth2ApplicationRevoke(ctx context.Context, doer, owner *user_model.User, app *auth_model.OAuth2Application, grant *auth_model.OAuth2Grant) { + record(ctx, audit_model.UserOAuth2ApplicationRevoke, doer, owner, grant, "Revoked OAuth2 grant for application %s of user %s.", app.Name, owner.Name) +} + +func RecordOAuth2ApplicationRemove(ctx context.Context, doer, user *user_model.User, app *auth_model.OAuth2Application) { + if user == nil { + record(ctx, audit_model.SystemOAuth2ApplicationRemove, doer, &systemObject, app, "Removed instance-wide OAuth2 application %s", app.Name) + } else if user.IsOrganization() { + record(ctx, audit_model.OrganizationOAuth2ApplicationRemove, doer, user, app, "Removed OAuth2 application %s of organization %s", app.Name, user.Name) + } else { + record(ctx, audit_model.UserOAuth2ApplicationRemove, doer, user, app, "Removed OAuth2 application %s of user %s", app.Name, user.Name) + } +} + +func RecordUserKeySSHAdd(ctx context.Context, doer, user *user_model.User, key *asymkey_model.PublicKey) { + record(ctx, audit_model.UserKeySSHAdd, doer, user, key, "Added SSH key %s for user %s.", key.Fingerprint, user.Name) +} + +func RecordUserKeySSHRemove(ctx context.Context, doer, user *user_model.User, key *asymkey_model.PublicKey) { + record(ctx, audit_model.UserKeySSHRemove, doer, user, key, "Removed SSH key %s of user %s.", key.Fingerprint, user.Name) +} + +func RecordUserKeyPrincipalAdd(ctx context.Context, doer, user *user_model.User, key *asymkey_model.PublicKey) { + record(ctx, audit_model.UserKeyPrincipalAdd, doer, user, key, "Added principal key %s for user %s.", key.Name, user.Name) +} + +func RecordUserKeyPrincipalRemove(ctx context.Context, doer, user *user_model.User, key *asymkey_model.PublicKey) { + record(ctx, audit_model.UserKeyPrincipalRemove, doer, user, key, "Removed principal key %s of user %s.", key.Name, user.Name) +} + +func RecordUserKeyGPGAdd(ctx context.Context, doer, user *user_model.User, key *asymkey_model.GPGKey) { + record(ctx, audit_model.UserKeyGPGAdd, doer, user, key, "Added GPG key %s for user %s.", key.KeyID, user.Name) +} + +func RecordUserKeyGPGRemove(ctx context.Context, doer, user *user_model.User, key *asymkey_model.GPGKey) { + record(ctx, audit_model.UserKeyGPGRemove, doer, user, key, "Removed GPG key %s of user %s.", key.KeyID, user.Name) +} + +func RecordSecretAdd(ctx context.Context, doer, owner *user_model.User, repo *repository_model.Repository, secret *secret_model.Secret) { + if owner == nil { + record(ctx, audit_model.RepositorySecretAdd, doer, repo, secret, "Added secret %s for repository %s.", secret.Name, repo.FullName()) + } else if owner.IsOrganization() { + record(ctx, audit_model.OrganizationSecretAdd, doer, owner, secret, "Added secret %s for organization %s.", secret.Name, owner.Name) + } else { + record(ctx, audit_model.UserSecretAdd, doer, owner, secret, "Added secret %s for user %s.", secret.Name, owner.Name) + } +} + +func RecordSecretUpdate(ctx context.Context, doer, owner *user_model.User, repo *repository_model.Repository, secret *secret_model.Secret) { + if owner == nil { + record(ctx, audit_model.RepositorySecretUpdate, doer, repo, secret, "Updated secret %s of repository %s.", secret.Name, repo.FullName()) + } else if owner.IsOrganization() { + record(ctx, audit_model.OrganizationSecretUpdate, doer, owner, secret, "Updated secret %s of organization %s.", secret.Name, owner.Name) + } else { + record(ctx, audit_model.UserSecretUpdate, doer, owner, secret, "Updated secret %s of user %s.", secret.Name, owner.Name) + } +} + +func RecordSecretRemove(ctx context.Context, doer, owner *user_model.User, repo *repository_model.Repository, secret *secret_model.Secret) { + if owner == nil { + record(ctx, audit_model.RepositorySecretRemove, doer, repo, secret, "Removed secret %s of repository %s.", secret.Name, repo.FullName()) + } else if owner.IsOrganization() { + record(ctx, audit_model.OrganizationSecretRemove, doer, owner, secret, "Removed secret %s of organization %s.", secret.Name, owner.Name) + } else { + record(ctx, audit_model.UserSecretRemove, doer, owner, secret, "Removed secret %s of user %s.", secret.Name, owner.Name) + } +} + +func RecordWebhookAdd(ctx context.Context, doer, owner *user_model.User, repo *repository_model.Repository, hook *webhook_model.Webhook) { + if owner == nil && repo == nil { + record(ctx, audit_model.SystemWebhookAdd, doer, &systemObject, hook, "Added instance-wide webhook %s.", hook.URL) + } else if repo != nil { + record(ctx, audit_model.RepositoryWebhookAdd, doer, repo, hook, "Added webhook %s for repository %s.", hook.URL, repo.FullName()) + } else if owner.IsOrganization() { + record(ctx, audit_model.OrganizationWebhookAdd, doer, owner, hook, "Added webhook %s for organization %s.", hook.URL, owner.Name) + } else { + record(ctx, audit_model.UserWebhookAdd, doer, owner, hook, "Added webhook %s for user %s.", hook.URL, owner.Name) + } +} + +func RecordWebhookUpdate(ctx context.Context, doer, owner *user_model.User, repo *repository_model.Repository, hook *webhook_model.Webhook) { + if owner == nil && repo == nil { + record(ctx, audit_model.SystemWebhookUpdate, doer, &systemObject, hook, "Updated instance-wide webhook %s.", hook.URL) + } else if repo != nil { + record(ctx, audit_model.RepositoryWebhookUpdate, doer, repo, hook, "Updated webhook %s of repository %s.", hook.URL, repo.FullName()) + } else if owner.IsOrganization() { + record(ctx, audit_model.OrganizationWebhookUpdate, doer, owner, hook, "Updated webhook %s of organization %s.", hook.URL, owner.Name) + } else { + record(ctx, audit_model.UserWebhookUpdate, doer, owner, hook, "Updated webhook %s of user %s.", hook.URL, owner.Name) + } +} + +func RecordWebhookRemove(ctx context.Context, doer, owner *user_model.User, repo *repository_model.Repository, hook *webhook_model.Webhook) { + if owner == nil && repo == nil { + record(ctx, audit_model.SystemWebhookRemove, doer, &systemObject, hook, "Removed instance-wide webhook %s.", hook.URL) + } else if repo != nil { + record(ctx, audit_model.RepositoryWebhookRemove, doer, repo, hook, "Removed webhook %s of repository %s.", hook.URL, repo.FullName()) + } else if owner.IsOrganization() { + record(ctx, audit_model.OrganizationWebhookRemove, doer, owner, hook, "Removed webhook %s of organization %s.", hook.URL, owner.Name) + } else { + record(ctx, audit_model.UserWebhookRemove, doer, owner, hook, "Removed webhook %s of user %s.", hook.URL, owner.Name) + } +} + +func RecordOrganizationTeamAdd(ctx context.Context, doer *user_model.User, org *organization_model.Organization, team *organization_model.Team) { + record(ctx, audit_model.OrganizationTeamAdd, doer, org, team, "Added team %s to organization %s.", team.Name, org.Name) +} + +func RecordOrganizationTeamUpdate(ctx context.Context, doer *user_model.User, org *organization_model.Organization, team *organization_model.Team) { + record(ctx, audit_model.OrganizationTeamUpdate, doer, org, team, "Updated settings of team %s/%s.", org.Name, team.Name) +} + +func RecordOrganizationTeamRemove(ctx context.Context, doer *user_model.User, org *organization_model.Organization, team *organization_model.Team) { + record(ctx, audit_model.OrganizationTeamRemove, doer, org, team, "Removed team %s from organization %s.", team.Name, org.Name) +} + +func RecordOrganizationTeamPermission(ctx context.Context, doer *user_model.User, org *organization_model.Organization, team *organization_model.Team) { + record(ctx, audit_model.OrganizationTeamPermission, doer, org, team, "Changed permission of team %s/%s to %s.", org.Name, team.Name, team.AccessMode.ToString()) +} + +func RecordOrganizationTeamMemberAdd(ctx context.Context, doer *user_model.User, org *organization_model.Organization, team *organization_model.Team, member *user_model.User) { + record(ctx, audit_model.OrganizationTeamMemberAdd, doer, org, team, "Added user %s to team %s/%s.", member.Name, org.Name, team.Name) +} + +func RecordOrganizationTeamMemberRemove(ctx context.Context, doer *user_model.User, org *organization_model.Organization, team *organization_model.Team, member *user_model.User) { + record(ctx, audit_model.OrganizationTeamMemberRemove, doer, org, team, "Removed user %s from team %s/%s.", member.Name, org.Name, team.Name) +} + +func RecordRepositoryCreate(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryCreate, doer, repo, repo, "Created repository %s.", repo.FullName()) +} + +func RecordRepositoryCreateFork(ctx context.Context, doer *user_model.User, repo, baseRepo *repository_model.Repository) { + record(ctx, audit_model.RepositoryCreateFork, doer, repo, repo, "Created fork %s of repository %s.", repo.FullName(), baseRepo.FullName()) +} + +func RecordRepositoryArchive(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryArchive, doer, repo, repo, "Archived repository %s.", repo.FullName()) +} + +func RecordRepositoryUnarchive(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryUnarchive, doer, repo, repo, "Unarchived repository %s.", repo.FullName()) +} + +func RecordRepositoryDelete(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryDelete, doer, repo, repo, "Deleted repository %s.", repo.FullName()) +} + +func RecordRepositoryName(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, previousName string) { + record(ctx, audit_model.RepositoryName, doer, repo, repo, "Changed repository name from %s to %s.", previousName, repo.FullName()) +} + +func RecordRepositoryVisibility(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + status := "public" + if repo.IsPrivate { + status = "private" + } + + record(ctx, audit_model.RepositoryVisibility, doer, repo, repo, "Changed visibility of repository %s to %s.", repo.FullName(), status) +} + +func RecordRepositoryConvertFork(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryConvertFork, doer, repo, repo, "Converted repository %s from fork to regular repository.", repo.FullName()) +} + +func RecordRepositoryConvertMirror(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryConvertMirror, doer, repo, repo, "Converted repository %s from pull mirror to regular repository.", repo.FullName()) +} + +func RecordRepositoryMirrorPushAdd(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, mirror *repository_model.PushMirror) { + record(ctx, audit_model.RepositoryMirrorPushAdd, doer, repo, mirror, "Added push mirror to %s for repository %s.", mirror.RemoteAddress, repo.FullName()) +} + +func RecordRepositoryMirrorPushRemove(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, mirror *repository_model.PushMirror) { + record(ctx, audit_model.RepositoryMirrorPushRemove, doer, repo, mirror, "Removed push mirror to %s for repository %s.", mirror.RemoteAddress, repo.FullName()) +} + +func RecordRepositorySigningVerification(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositorySigningVerification, doer, repo, repo, "Changed signing verification of repository %s to %s.", repo.FullName(), repo.TrustModel.String()) +} + +func RecordRepositoryTransferStart(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, newOwner *user_model.User) { + record(ctx, audit_model.RepositoryTransferStart, doer, repo, repo, "Started repository transfer of %s to %s.", repo.FullName(), newOwner.Name) +} + +func RecordRepositoryTransferFinish(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, oldOwner *user_model.User) { + record(ctx, audit_model.RepositoryTransferFinish, doer, repo, repo, "Transferred repository %s from %s to %s.", repo.FullName(), oldOwner.Name, repo.OwnerName) +} + +func RecordRepositoryTransferCancel(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryTransferCancel, doer, repo, repo, "Canceled transfer of repository %s.", repo.FullName()) +} + +func RecordRepositoryWikiDelete(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryWikiDelete, doer, repo, repo, "Deleted wiki of repository %s.", repo.FullName()) +} + +func RecordRepositoryCollaboratorAdd(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, collaborator *user_model.User, accessMode perm_model.AccessMode) { + record(ctx, audit_model.RepositoryCollaboratorAdd, doer, repo, collaborator, "Added user %s as collaborator for repository %s with access mode %s.", collaborator.Name, repo.FullName(), accessMode.ToString()) +} + +func RecordRepositoryCollaboratorAccess(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, collaborator *user_model.User, accessMode perm_model.AccessMode) { + record(ctx, audit_model.RepositoryCollaboratorAccess, doer, repo, collaborator, "Changed access mode of collaborator %s of repository %s to %s.", collaborator.Name, repo.FullName(), accessMode.ToString()) +} + +func RecordRepositoryCollaboratorRemove(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, collaborator *user_model.User) { + record(ctx, audit_model.RepositoryCollaboratorRemove, doer, repo, collaborator, "Removed collaborator %s from repository %s.", collaborator.Name, repo.FullName()) +} + +func RecordRepositoryCollaboratorTeamAdd(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, team *organization_model.Team) { + record(ctx, audit_model.RepositoryCollaboratorTeamAdd, doer, repo, team, "Added team %s as collaborator for repository %s.", team.Name, repo.FullName()) +} + +func RecordRepositoryCollaboratorTeamRemove(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, team *organization_model.Team) { + record(ctx, audit_model.RepositoryCollaboratorTeamRemove, doer, repo, team, "Removed team %s as collaborator from repository %s.", team.Name, repo.FullName()) +} + +func RecordRepositoryBranchDefault(ctx context.Context, doer *user_model.User, repo *repository_model.Repository) { + record(ctx, audit_model.RepositoryBranchDefault, doer, repo, repo, "Changed default branch of repository %s to %s.", repo.FullName(), repo.DefaultBranch) +} + +func RecordRepositoryBranchProtectionAdd(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, protectBranch *git_model.ProtectedBranch) { + record(ctx, audit_model.RepositoryBranchProtectionAdd, doer, repo, protectBranch, "Added branch protection %s for repository %s.", protectBranch.RuleName, repo.FullName()) +} + +func RecordRepositoryBranchProtectionUpdate(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, protectBranch *git_model.ProtectedBranch) { + record(ctx, audit_model.RepositoryBranchProtectionUpdate, doer, repo, protectBranch, "Updated branch protection %s for repository %s.", protectBranch.RuleName, repo.FullName()) +} + +func RecordRepositoryBranchProtectionRemove(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, protectBranch *git_model.ProtectedBranch) { + record(ctx, audit_model.RepositoryBranchProtectionRemove, doer, repo, protectBranch, "Removed branch protection %s from repository %s.", protectBranch.RuleName, repo.FullName()) +} + +func RecordRepositoryTagProtectionAdd(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, protectedTag *git_model.ProtectedTag) { + record(ctx, audit_model.RepositoryTagProtectionAdd, doer, repo, protectedTag, "Added tag protection %s for repository %s.", protectedTag.NamePattern, repo.FullName()) +} + +func RecordRepositoryTagProtectionUpdate(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, protectedTag *git_model.ProtectedTag) { + record(ctx, audit_model.RepositoryTagProtectionUpdate, doer, repo, protectedTag, "Updated tag protection %s for repository %s.", protectedTag.NamePattern, repo.FullName()) +} + +func RecordRepositoryTagProtectionRemove(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, protectedTag *git_model.ProtectedTag) { + record(ctx, audit_model.RepositoryTagProtectionRemove, doer, repo, protectedTag, "Removed tag protection %s for repository %s.", protectedTag.NamePattern, repo.FullName()) +} + +func RecordRepositoryDeployKeyAdd(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, deployKey *asymkey_model.DeployKey) { + record(ctx, audit_model.RepositoryDeployKeyAdd, doer, repo, deployKey, "Added deploy key %s for repository %s.", deployKey.Name, repo.FullName()) +} + +func RecordRepositoryDeployKeyRemove(ctx context.Context, doer *user_model.User, repo *repository_model.Repository, deployKey *asymkey_model.DeployKey) { + record(ctx, audit_model.RepositoryDeployKeyRemove, doer, repo, deployKey, "Removed deploy key %s from repository %s.", deployKey.Name, repo.FullName()) +} + +func RecordSystemStartup(ctx context.Context, doer *user_model.User, version string) { + // Do not change this 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. + record(ctx, audit_model.SystemStartup, doer, &systemObject, &systemObject, "System started [Gitea %s]", version) +} + +func RecordSystemShutdown(ctx context.Context, doer *user_model.User) { + record(ctx, audit_model.SystemShutdown, doer, &systemObject, &systemObject, "System shutdown") +} + +func RecordSystemAuthenticationSourceAdd(ctx context.Context, doer *user_model.User, authSource *auth_model.Source) { + record(ctx, audit_model.SystemAuthenticationSourceAdd, doer, &systemObject, authSource, "Created authentication source %s of type %s.", authSource.Name, authSource.Type.String()) +} + +func RecordSystemAuthenticationSourceUpdate(ctx context.Context, doer *user_model.User, authSource *auth_model.Source) { + record(ctx, audit_model.SystemAuthenticationSourceUpdate, doer, &systemObject, authSource, "Updated authentication source %s.", authSource.Name) +} + +func RecordSystemAuthenticationSourceRemove(ctx context.Context, doer *user_model.User, authSource *auth_model.Source) { + record(ctx, audit_model.SystemAuthenticationSourceRemove, doer, &systemObject, authSource, "Removed authentication source %s.", authSource.Name) +} diff --git a/services/auth/auth.go b/services/auth/auth.go index 43ff95f05302e..6281d546826d1 100644 --- a/services/auth/auth.go +++ b/services/auth/auth.go @@ -95,7 +95,7 @@ func handleSignIn(resp http.ResponseWriter, req *http.Request, sess SessionStore opts := &user_service.UpdateOptions{ Language: optional.Some(lc.Language()), } - if err := user_service.UpdateUser(req.Context(), user, opts); err != nil { + if err := user_service.UpdateUser(req.Context(), user, user, opts); err != nil { log.Error(fmt.Sprintf("Error updating user language [user: %d, locale: %s]", user.ID, user.Language)) return } diff --git a/services/auth/basic.go b/services/auth/basic.go index 6a05b2fe53043..ececdd96ccf29 100644 --- a/services/auth/basic.go +++ b/services/auth/basic.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/audit" ) // Ensure the struct implements the interface. @@ -174,6 +175,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error { if ok, err := twofa.ValidateTOTP(req.Header.Get("X-Gitea-OTP")); err != nil { return err } else if !ok { + audit.RecordUserAuthenticationFailTwoFactor(req.Context(), u) + return util.NewInvalidArgumentErrorf("invalid provided OTP") } return nil diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index 36b4ef68f42f4..e69dab44e9b7c 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/audit" gouuid "github.com/google/uuid" ) @@ -170,5 +171,7 @@ func (r *ReverseProxy) newUser(req *http.Request) *user_model.User { return nil } + audit.RecordUserCreate(req.Context(), user_model.NewAuthenticationSourceUser(), user) + return user } diff --git a/services/auth/source.go b/services/auth/source.go index 69b71a6deaecf..e4cf22beaaddf 100644 --- a/services/auth/source.go +++ b/services/auth/source.go @@ -9,10 +9,11 @@ import ( "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" ) // DeleteSource deletes a AuthSource record in DB. -func DeleteSource(ctx context.Context, source *auth.Source) error { +func DeleteSource(ctx context.Context, doer *user_model.User, source *auth.Source) error { count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID}) if err != nil { return err @@ -38,5 +39,10 @@ func DeleteSource(ctx context.Context, source *auth.Source) error { } _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)) + + if err == nil { + audit.RecordSystemAuthenticationSourceRemove(ctx, doer, source) + } + return err } diff --git a/services/auth/source/ldap/source_authenticate.go b/services/auth/source/ldap/source_authenticate.go index 01cb743720599..082b8b609a37a 100644 --- a/services/auth/source/ldap/source_authenticate.go +++ b/services/auth/source/ldap/source_authenticate.go @@ -14,6 +14,7 @@ import ( auth_module "code.gitea.io/gitea/modules/auth" "code.gitea.io/gitea/modules/optional" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -60,7 +61,7 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u opts.IsRestricted = optional.Some(sr.IsRestricted) } if opts.IsAdmin.Has() || opts.IsRestricted.Has() { - if err := user_service.UpdateUser(ctx, user, opts); err != nil { + if err := user_service.UpdateUser(ctx, user_model.NewAuthenticationSourceUser(), user, opts); err != nil { return nil, err } } @@ -68,9 +69,18 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u } if user != nil { - if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sr.SSHPublicKey) { - if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { - return user, err + if isAttributeSSHPublicKeySet { + if addedKeys, deletedKeys := asymkey_model.SynchronizePublicKeys(ctx, user, source.authSource, sr.SSHPublicKey); len(addedKeys) > 0 || len(deletedKeys) > 0 { + for _, key := range addedKeys { + audit.RecordUserKeySSHAdd(ctx, user_model.NewAuthenticationSourceUser(), user, key) + } + for _, key := range deletedKeys { + audit.RecordUserKeySSHRemove(ctx, user_model.NewAuthenticationSourceUser(), user, key) + } + + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { + return user, err + } } } } else { @@ -94,9 +104,17 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u return user, err } - if isAttributeSSHPublicKeySet && asymkey_model.AddPublicKeysBySource(ctx, user, source.authSource, sr.SSHPublicKey) { - if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { - return user, err + audit.RecordUserCreate(ctx, user_model.NewAuthenticationSourceUser(), user) + + if isAttributeSSHPublicKeySet { + if addedKeys := asymkey_model.AddPublicKeysBySource(ctx, user, source.authSource, sr.SSHPublicKey); len(addedKeys) > 0 { + for _, key := range addedKeys { + audit.RecordUserKeySSHAdd(ctx, user_model.NewAuthenticationSourceUser(), user, key) + } + + if err := asymkey_service.RewriteAllPublicKeys(ctx); err != nil { + return user, err + } } } if len(source.AttributeAvatar) > 0 { diff --git a/services/auth/source/ldap/source_sync.go b/services/auth/source/ldap/source_sync.go index a6d6d2a0f2fc3..1f7134631ceb0 100644 --- a/services/auth/source/ldap/source_sync.go +++ b/services/auth/source/ldap/source_sync.go @@ -17,6 +17,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" source_service "code.gitea.io/gitea/services/auth/source" user_service "code.gitea.io/gitea/services/user" ) @@ -132,22 +133,37 @@ 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) - } - - if err == nil && isAttributeSSHPublicKeySet { - log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.authSource.Name, usr.Name) - if asymkey_model.AddPublicKeysBySource(ctx, usr, source.authSource, su.SSHPublicKey) { - sshKeysNeedUpdate = true + } else { + audit.RecordUserCreate(ctx, user_model.NewAuthenticationSourceUser(), usr) + + if isAttributeSSHPublicKeySet { + log.Trace("SyncExternalUsers[%s]: Adding LDAP Public SSH Keys for user %s", source.authSource.Name, usr.Name) + if addedKeys := asymkey_model.AddPublicKeysBySource(ctx, usr, source.authSource, su.SSHPublicKey); len(addedKeys) > 0 { + sshKeysNeedUpdate = true + + for _, key := range addedKeys { + audit.RecordUserKeySSHAdd(ctx, user_model.NewAuthenticationSourceUser(), usr, key) + } + } } - } - if err == nil && len(source.AttributeAvatar) > 0 { - _ = user_service.UploadAvatar(ctx, usr, su.Avatar) + if len(source.AttributeAvatar) > 0 { + _ = user_service.UploadAvatar(ctx, usr, su.Avatar) + } } } else if updateExisting { // Synchronize SSH Public Key if that attribute is set - if isAttributeSSHPublicKeySet && asymkey_model.SynchronizePublicKeys(ctx, usr, source.authSource, su.SSHPublicKey) { - sshKeysNeedUpdate = true + if isAttributeSSHPublicKeySet { + if addedKeys, deletedKeys := asymkey_model.SynchronizePublicKeys(ctx, usr, source.authSource, su.SSHPublicKey); len(addedKeys) > 0 || len(deletedKeys) > 0 { + sshKeysNeedUpdate = true + + for _, key := range addedKeys { + audit.RecordUserKeySSHAdd(ctx, user_model.NewAuthenticationSourceUser(), usr, key) + } + for _, key := range deletedKeys { + audit.RecordUserKeySSHRemove(ctx, user_model.NewAuthenticationSourceUser(), usr, key) + } + } } // Check if user data has changed @@ -170,11 +186,11 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { opts.IsRestricted = optional.Some(su.IsRestricted) } - if err := user_service.UpdateUser(ctx, usr, opts); err != nil { + if err := user_service.UpdateUser(ctx, user_model.NewAuthenticationSourceUser(), usr, opts); err != nil { log.Error("SyncExternalUsers[%s]: Error updating user %s: %v", source.authSource.Name, usr.Name, err) } - if err := user_service.ReplacePrimaryEmailAddress(ctx, usr, su.Mail); err != nil { + if err := user_service.ReplacePrimaryEmailAddress(ctx, user_model.NewAuthenticationSourceUser(), usr, su.Mail); err != nil { log.Error("SyncExternalUsers[%s]: Error updating user %s primary email %s: %v", source.authSource.Name, usr.Name, su.Mail, err) } } @@ -220,7 +236,7 @@ func (source *Source) Sync(ctx context.Context, updateExisting bool) error { opts := &user_service.UpdateOptions{ IsActive: optional.Some(false), } - if err := user_service.UpdateUser(ctx, usr, opts); err != nil { + if err := user_service.UpdateUser(ctx, user_model.NewAuthenticationSourceUser(), usr, opts); err != nil { log.Error("SyncExternalUsers[%s]: Error deactivating user %s: %v", source.authSource.Name, usr.Name, err) } } diff --git a/services/auth/source/pam/source_authenticate.go b/services/auth/source/pam/source_authenticate.go index 6fd02dc29f87c..c5fcbc1ac617c 100644 --- a/services/auth/source/pam/source_authenticate.go +++ b/services/auth/source/pam/source_authenticate.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/auth/pam" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/audit" "github.com/google/uuid" ) @@ -67,6 +68,8 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u return user, err } + audit.RecordUserCreate(ctx, user_model.NewAuthenticationSourceUser(), user) + return user, nil } diff --git a/services/auth/source/smtp/source_authenticate.go b/services/auth/source/smtp/source_authenticate.go index b2e94933a6d2e..4005d40574f31 100644 --- a/services/auth/source/smtp/source_authenticate.go +++ b/services/auth/source/smtp/source_authenticate.go @@ -14,6 +14,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/audit" ) // Authenticate queries if the provided login/password is authenticates against the SMTP server @@ -83,6 +84,8 @@ func (source *Source) Authenticate(ctx context.Context, user *user_model.User, u return user, err } + audit.RecordUserCreate(ctx, user_model.NewAuthenticationSourceUser(), user) + return user, nil } diff --git a/services/auth/source/source_group_sync.go b/services/auth/source/source_group_sync.go index 9cb7d4165ccd0..de5e5f7ed7cde 100644 --- a/services/auth/source/source_group_sync.go +++ b/services/auth/source/source_group_sync.go @@ -11,6 +11,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/audit" org_service "code.gitea.io/gitea/services/org" ) @@ -104,11 +105,15 @@ func syncGroupsToTeamsCached(ctx context.Context, user *user_model.User, orgTeam log.Error("group sync: Could not add user to team: %v", err) return err } + + audit.RecordOrganizationTeamMemberAdd(ctx, user_model.NewAuthenticationSourceUser(), org, team, user) } else if action == syncRemove && isMember { if err := org_service.RemoveTeamMember(ctx, team, user); err != nil { log.Error("group sync: Could not remove user from team: %v", err) return err } + + audit.RecordOrganizationTeamMemberRemove(ctx, user_model.NewAuthenticationSourceUser(), org, team, user) } } } diff --git a/services/auth/sspi.go b/services/auth/sspi.go index 7f8a03a4c67da..a734cfcd4a156 100644 --- a/services/auth/sspi.go +++ b/services/auth/sspi.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/services/audit" "code.gitea.io/gitea/services/auth/source/sspi" gitea_context "code.gitea.io/gitea/services/context" @@ -180,6 +181,8 @@ func (s *SSPI) newUser(ctx context.Context, username string, cfg *sspi.Source) ( return nil, err } + audit.RecordUserCreate(ctx, user_model.NewAuthenticationSourceUser(), user) + return user, nil } diff --git a/services/context/context.go b/services/context/context.go index 812a8c27eeb53..ef412e92cc2ce 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -213,6 +213,7 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations ctx.Data["DisableStars"] = setting.Repository.DisableStars ctx.Data["EnableActions"] = setting.Actions.Enabled && !unit.TypeActions.UnitGlobalDisabled() + ctx.Data["EnableAuditLogs"] = setting.Audit.Enabled ctx.Data["ManifestData"] = setting.ManifestData ctx.Data["AllLangs"] = translation.AllLangs() diff --git a/services/context/org.go b/services/context/org.go index bf482fa7543ce..3ee3c00e7d4ce 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -97,7 +97,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { if ctx.Org == nil { ctx.Org = &Organization{} } - ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) + ctx.Org.Organization = organization.OrgFromUser(ctx.ContextUser) } else { // ContextUser is an individual User return diff --git a/services/cron/tasks_extended.go b/services/cron/tasks_extended.go index 0018c5facc5d7..af87c864de3b3 100644 --- a/services/cron/tasks_extended.go +++ b/services/cron/tasks_extended.go @@ -28,9 +28,9 @@ func registerDeleteInactiveUsers() { Schedule: "@annually", }, OlderThan: time.Minute * time.Duration(setting.Service.ActiveCodeLives), - }, func(ctx context.Context, _ *user_model.User, config Config) error { + }, func(ctx context.Context, doer *user_model.User, config Config) error { olderThanConfig := config.(*OlderThanConfig) - return user_service.DeleteInactiveUsers(ctx, olderThanConfig.OlderThan) + return user_service.DeleteInactiveUsers(ctx, doer, olderThanConfig.OlderThan) }) } diff --git a/services/externalaccount/user.go b/services/externalaccount/user.go index b53e33654a231..205d44e87710b 100644 --- a/services/externalaccount/user.go +++ b/services/externalaccount/user.go @@ -13,6 +13,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/audit" "github.com/markbates/goth" ) @@ -54,6 +55,8 @@ func LinkAccountToUser(ctx context.Context, user *user_model.User, gothUser goth return err } + audit.RecordUserExternalLoginAdd(ctx, user, user, externalLoginUser) + externalID := externalLoginUser.ExternalID var tp structs.GitServiceType diff --git a/services/org/org.go b/services/org/org.go index c19572a123062..59fdd58c6a248 100644 --- a/services/org/org.go +++ b/services/org/org.go @@ -15,11 +15,12 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/audit" repo_service "code.gitea.io/gitea/services/repository" ) // DeleteOrganization completely and permanently deletes everything of organization. -func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge bool) error { +func DeleteOrganization(ctx context.Context, doer *user_model.User, org *org_model.Organization, purge bool) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -56,6 +57,8 @@ func DeleteOrganization(ctx context.Context, org *org_model.Organization, purge return err } + audit.RecordUserDelete(ctx, doer, org.AsUser()) + // FIXME: system notice // Note: There are something just cannot be roll back, // so just keep error logs of those operations. diff --git a/services/org/org_test.go b/services/org/org_test.go index e7d2a18ea9688..235def3736330 100644 --- a/services/org/org_test.go +++ b/services/org/org_test.go @@ -21,18 +21,18 @@ func TestMain(m *testing.M) { func TestDeleteOrganization(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}) org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6}) - assert.NoError(t, DeleteOrganization(db.DefaultContext, org, false)) + assert.NoError(t, DeleteOrganization(db.DefaultContext, user, org, false)) unittest.AssertNotExistsBean(t, &organization.Organization{ID: 6}) unittest.AssertNotExistsBean(t, &organization.OrgUser{OrgID: 6}) unittest.AssertNotExistsBean(t, &organization.Team{OrgID: 6}) org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3}) - err := DeleteOrganization(db.DefaultContext, org, false) + err := DeleteOrganization(db.DefaultContext, user, org, false) assert.Error(t, err) assert.True(t, models.IsErrUserOwnRepos(err)) - user := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 5}) - assert.Error(t, DeleteOrganization(db.DefaultContext, user, false)) + assert.Error(t, DeleteOrganization(db.DefaultContext, user, organization.OrgFromUser(user), false)) unittest.CheckConsistencyFor(t, &user_model.User{}, &organization.Team{}) } diff --git a/services/org/team.go b/services/org/team.go index 3688e684339db..e8763fc58c810 100644 --- a/services/org/team.go +++ b/services/org/team.go @@ -76,9 +76,9 @@ func NewTeam(ctx context.Context, t *organization.Team) (err error) { // Add all repositories to the team if it has access to all of them. if t.IncludesAllRepositories { - err = repo_service.AddAllRepositoriesToTeam(ctx, t) + _, err = repo_service.AddAllRepositoriesToTeam(ctx, t) if err != nil { - return fmt.Errorf("addAllRepositories: %w", err) + return fmt.Errorf("AddAllRepositoriesToTeam: %w", err) } } @@ -154,9 +154,9 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA // Add all repositories to the team if it has access to all of them. if includeAllChanged && t.IncludesAllRepositories { - err = repo_service.AddAllRepositoriesToTeam(ctx, t) + _, err = repo_service.AddAllRepositoriesToTeam(ctx, t) if err != nil { - return fmt.Errorf("addAllRepositories: %w", err) + return fmt.Errorf("AddAllRepositoriesToTeam: %w", err) } } diff --git a/services/repository/branch.go b/services/repository/branch.go index 600ba96e92e17..70b566539860c 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -28,6 +28,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/audit" notify_service "code.gitea.io/gitea/services/notify" files_service "code.gitea.io/gitea/services/repository/files" @@ -570,7 +571,7 @@ func AddAllRepoBranchesToSyncQueue(ctx context.Context) error { return nil } -func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, newBranchName string) error { +func SetRepoDefaultBranch(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, newBranchName string) error { if repo.DefaultBranch == newBranchName { return nil } @@ -607,6 +608,8 @@ func SetRepoDefaultBranch(ctx context.Context, repo *repo_model.Repository, gitR return err } + audit.RecordRepositoryBranchDefault(ctx, doer, repo) + if !repo.IsEmpty { if err := AddRepoToLicenseUpdaterQueue(&LicenseUpdaterOptions{ RepoID: repo.ID, diff --git a/services/repository/collaboration.go b/services/repository/collaboration.go index b5fc523623a8b..1549c8faecbd1 100644 --- a/services/repository/collaboration.go +++ b/services/repository/collaboration.go @@ -14,11 +14,12 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" "xorm.io/builder" ) -func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u *user_model.User, mode perm.AccessMode) error { +func AddOrUpdateCollaborator(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, u *user_model.User, mode perm.AccessMode) error { // only allow valid access modes, read, write and admin if mode < perm.AccessModeRead || mode > perm.AccessModeAdmin { return perm.ErrInvalidAccessMode @@ -32,7 +33,8 @@ func AddOrUpdateCollaborator(ctx context.Context, repo *repo_model.Repository, u return user_model.ErrBlockedUser } - return db.WithTx(ctx, func(ctx context.Context) error { + changed := 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, @@ -52,20 +54,32 @@ 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 + changed = true + } else { + if err = db.Insert(ctx, &repo_model.Collaboration{ + RepoID: repo.ID, + UserID: u.ID, + Mode: mode, + }); err != nil { + return err + } + changed = true } return access_model.RecalculateUserAccess(ctx, repo, u.ID) - }) + }); err != nil { + return err + } + + if changed { + audit.RecordRepositoryCollaboratorAdd(ctx, doer, repo, u, mode) + } + + return nil } // DeleteCollaboration removes collaboration relation between the user and repository. -func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, collaborator *user_model.User) (err error) { +func DeleteCollaboration(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, collaborator *user_model.User) (err error) { collaboration := &repo_model.Collaboration{ RepoID: repo.ID, UserID: collaborator.ID, @@ -104,7 +118,13 @@ func DeleteCollaboration(ctx context.Context, repo *repo_model.Repository, colla return err } - return committer.Commit() + if err := committer.Commit(); err != nil { + return err + } + + audit.RecordRepositoryCollaboratorRemove(ctx, doer, repo, collaborator) + + return nil } func ReconsiderRepoIssuesAssignee(ctx context.Context, repo *repo_model.Repository, user *user_model.User) error { diff --git a/services/repository/collaboration_test.go b/services/repository/collaboration_test.go index 2b9a5d0b8bab1..731d71d48b8db 100644 --- a/services/repository/collaboration_test.go +++ b/services/repository/collaboration_test.go @@ -22,7 +22,7 @@ func TestRepository_AddCollaborator(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) assert.NoError(t, repo.LoadOwner(db.DefaultContext)) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) - assert.NoError(t, AddOrUpdateCollaborator(db.DefaultContext, repo, user, perm.AccessModeWrite)) + assert.NoError(t, AddOrUpdateCollaborator(db.DefaultContext, user, repo, user, perm.AccessModeWrite)) unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repoID}, &user_model.User{ID: userID}) } testSuccess(1, 4) @@ -37,10 +37,10 @@ func TestRepository_DeleteCollaboration(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) assert.NoError(t, repo.LoadOwner(db.DefaultContext)) - assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, user, repo, user)) unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) - assert.NoError(t, DeleteCollaboration(db.DefaultContext, repo, user)) + assert.NoError(t, DeleteCollaboration(db.DefaultContext, user, repo, user)) unittest.AssertNotExistsBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: user.ID}) unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID}) diff --git a/services/repository/create.go b/services/repository/create.go index 14e625d962a07..cbc6eec880dd6 100644 --- a/services/repository/create.go +++ b/services/repository/create.go @@ -457,7 +457,7 @@ func CreateRepositoryByExample(ctx context.Context, doer, u *user_model.User, re return fmt.Errorf("IsUserRepoAdmin: %w", err) } else if !isAdmin { // Make creator repo admin if it wasn't assigned automatically - if err = AddOrUpdateCollaborator(ctx, repo, doer, perm.AccessModeAdmin); err != nil { + if err = AddOrUpdateCollaborator(ctx, doer, repo, doer, perm.AccessModeAdmin); err != nil { return fmt.Errorf("AddCollaborator: %w", err) } } diff --git a/services/repository/delete_test.go b/services/repository/delete_test.go index 869b8af11d57d..31fa5e2ad3554 100644 --- a/services/repository/delete_test.go +++ b/services/repository/delete_test.go @@ -37,13 +37,13 @@ func TestTeam_RemoveRepository(t *testing.T) { testSuccess := func(teamID, repoID int64) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) - assert.NoError(t, repo_service.RemoveRepositoryFromTeam(db.DefaultContext, team, repoID)) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) + assert.NoError(t, repo_service.RemoveRepositoryFromTeam(db.DefaultContext, user_model.NewGhostUser(), team, repo)) unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID}) unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID}) } testSuccess(2, 3) testSuccess(2, 5) - testSuccess(1, unittest.NonexistentID) } func TestDeleteOwnerRepositoriesDirectly(t *testing.T) { diff --git a/services/repository/fork.go b/services/repository/fork.go index bc4fdf85627b0..596a1c617404e 100644 --- a/services/repository/fork.go +++ b/services/repository/fork.go @@ -20,6 +20,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/audit" notify_service "code.gitea.io/gitea/services/notify" "xorm.io/builder" @@ -217,11 +218,13 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork notify_service.ForkRepository(ctx, doer, opts.BaseRepo, repo) + audit.RecordRepositoryCreateFork(ctx, doer, repo, opts.BaseRepo) + return repo, nil } // ConvertForkToNormalRepository convert the provided repo from a forked repo to normal repo -func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Repository) error { +func ConvertForkToNormalRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) error { err := db.WithTx(ctx, func(ctx context.Context) error { repo, err := repo_model.GetRepositoryByID(ctx, repo.ID) if err != nil { @@ -247,8 +250,13 @@ func ConvertForkToNormalRepository(ctx context.Context, repo *repo_model.Reposit return nil }) + if err != nil { + return err + } + + audit.RecordRepositoryConvertFork(ctx, doer, repo) - return err + return nil } type findForksOptions struct { diff --git a/services/repository/repo_team.go b/services/repository/repo_team.go index 29c67893b23ca..f9b9582ae95b3 100644 --- a/services/repository/repo_team.go +++ b/services/repository/repo_team.go @@ -13,20 +13,28 @@ import ( "code.gitea.io/gitea/models/organization" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/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, doer *user_model.User, 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.RecordRepositoryCollaboratorTeamAdd(ctx, doer, repo, t) + + return nil } func addRepositoryToTeam(ctx context.Context, t *organization.Team, repo *repo_model.Repository) (err error) { @@ -61,8 +69,9 @@ 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 { +func AddAllRepositoriesToTeam(ctx context.Context, t *organization.Team) ([]*repo_model.Repository, error) { + added := make([]*repo_model.Repository, 0, 5) + return added, db.WithTx(ctx, func(ctx context.Context) error { orgRepos, err := organization.GetOrgRepositories(ctx, t.OrgID) if err != nil { return fmt.Errorf("get org repos: %w", err) @@ -73,6 +82,7 @@ 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) } } @@ -146,8 +156,8 @@ func removeAllRepositoriesFromTeam(ctx context.Context, t *organization.Team) (e // RemoveRepositoryFromTeam removes repository from team of organization. // If the team shall include all repositories the request is ignored. -func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID int64) error { - if !HasRepository(ctx, t, repoID) { +func RemoveRepositoryFromTeam(ctx context.Context, doer *user_model.User, t *organization.Team, repo *repo_model.Repository) error { + if !HasRepository(ctx, t, repo.ID) { return nil } @@ -155,11 +165,6 @@ func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID return nil } - repo, err := repo_model.GetRepositoryByID(ctx, repoID) - if err != nil { - return err - } - ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -170,7 +175,13 @@ func RemoveRepositoryFromTeam(ctx context.Context, t *organization.Team, repoID return err } - return committer.Commit() + if err := committer.Commit(); err != nil { + return err + } + + audit.RecordRepositoryCollaboratorTeamRemove(ctx, doer, repo, t) + + return nil } // removeRepositoryFromTeam removes a repository from a team and recalculates access diff --git a/services/repository/repo_team_test.go b/services/repository/repo_team_test.go index 70b1b47d0af84..dc5bad7a4ed7e 100644 --- a/services/repository/repo_team_test.go +++ b/services/repository/repo_team_test.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -20,7 +21,7 @@ func TestTeam_AddRepository(t *testing.T) { testSuccess := func(teamID, repoID int64) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) - assert.NoError(t, TeamAddRepository(db.DefaultContext, team, repo)) + assert.NoError(t, TeamAddRepository(db.DefaultContext, user_model.NewGhostUser(), team, repo)) unittest.AssertExistsAndLoadBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID}) unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID}) } @@ -29,6 +30,6 @@ func TestTeam_AddRepository(t *testing.T) { team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) - assert.Error(t, TeamAddRepository(db.DefaultContext, team, repo)) + assert.Error(t, TeamAddRepository(db.DefaultContext, user_model.NewGhostUser(), team, repo)) unittest.CheckConsistencyFor(t, &organization.Team{ID: 1}, &repo_model.Repository{ID: 1}) } diff --git a/services/repository/repository.go b/services/repository/repository.go index 59b4491132da9..01374502b8ac8 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -22,6 +22,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/audit" notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) @@ -49,6 +50,8 @@ func CreateRepository(ctx context.Context, doer, owner *user_model.User, opts Cr notify_service.CreateRepository(ctx, doer, owner, repo) + audit.RecordRepositoryCreate(ctx, doer, repo) + return repo, nil } @@ -67,7 +70,13 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod return err } - return packages_model.UnlinkRepositoryFromAllPackages(ctx, repo.ID) + if err := packages_model.UnlinkRepositoryFromAllPackages(ctx, repo.ID); err != nil { + return err + } + + audit.RecordRepositoryDelete(ctx, doer, repo) + + return nil } // PushCreateRepo creates a repository when a new repository is pushed to an appropriate namespace @@ -129,7 +138,7 @@ func UpdateRepository(ctx context.Context, repo *repo_model.Repository, visibili return committer.Commit() } -func UpdateRepositoryVisibility(ctx context.Context, repo *repo_model.Repository, isPrivate bool) (err error) { +func UpdateRepositoryVisibility(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, isPrivate bool) (err error) { ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -143,15 +152,21 @@ func UpdateRepositoryVisibility(ctx context.Context, repo *repo_model.Repository return fmt.Errorf("UpdateRepositoryVisibility: %w", err) } - return committer.Commit() + if err = committer.Commit(); err != nil { + return err + } + + audit.RecordRepositoryVisibility(ctx, doer, repo) + + return nil } -func MakeRepoPublic(ctx context.Context, repo *repo_model.Repository) (err error) { - return UpdateRepositoryVisibility(ctx, repo, false) +func MakeRepoPublic(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) (err error) { + return UpdateRepositoryVisibility(ctx, doer, repo, false) } -func MakeRepoPrivate(ctx context.Context, repo *repo_model.Repository) (err error) { - return UpdateRepositoryVisibility(ctx, repo, true) +func MakeRepoPrivate(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) (err error) { + return UpdateRepositoryVisibility(ctx, doer, repo, true) } // LinkedRepository returns the linked repo if any diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 9a643469d9db8..0d876bbe88f03 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/audit" notify_service "code.gitea.io/gitea/services/notify" ) @@ -58,10 +59,14 @@ func TransferOwnership(ctx context.Context, doer, newOwner *user_model.User, rep return err } + audit.RecordRepositoryTransferFinish(ctx, doer, newRepo, oldOwner) + for _, team := range teams { if err := addRepositoryToTeam(ctx, team, newRepo); err != nil { return err } + + audit.RecordRepositoryCollaboratorTeamAdd(ctx, doer, newRepo, team) } notify_service.TransferRepository(ctx, doer, repo, oldOwner.Name) @@ -380,6 +385,9 @@ func ChangeRepositoryName(ctx context.Context, doer *user_model.User, repo *repo releaser() repo.Name = newRepoName + + audit.RecordRepositoryName(ctx, doer, repo, oldRepoName) + notify_service.RenameRepository(ctx, doer, repo, oldRepoName) return nil @@ -418,7 +426,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use return err } if !hasAccess { - if err := AddOrUpdateCollaborator(ctx, repo, newOwner, perm.AccessModeRead); err != nil { + if err := AddOrUpdateCollaborator(ctx, doer, repo, newOwner, perm.AccessModeRead); err != nil { return err } } @@ -429,6 +437,8 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use return err } + audit.RecordRepositoryTransferStart(ctx, doer, repo, newOwner) + // notify users who are able to accept / reject transfer notify_service.RepoPendingTransfer(ctx, doer, newOwner, repo) diff --git a/services/secrets/secrets.go b/services/secrets/secrets.go index 031c474dd7265..051c507bc5f9c 100644 --- a/services/secrets/secrets.go +++ b/services/secrets/secrets.go @@ -7,42 +7,52 @@ import ( "context" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" secret_model "code.gitea.io/gitea/models/secret" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/audit" ) -func CreateOrUpdateSecret(ctx context.Context, ownerID, repoID int64, name, data string) (*secret_model.Secret, bool, error) { +func CreateOrUpdateSecret(ctx context.Context, doer, owner *user_model.User, repo *repo_model.Repository, name, data string) (*secret_model.Secret, bool, error) { if err := ValidateName(name); err != nil { return nil, false, err } - s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ - OwnerID: ownerID, - RepoID: repoID, + ss, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ + OwnerID: tryGetOwnerID(owner), + RepoID: tryGetRepositoryID(repo), Name: name, }) if err != nil { return nil, false, err } - if len(s) == 0 { - s, err := secret_model.InsertEncryptedSecret(ctx, ownerID, repoID, name, data) + if len(ss) == 0 { + s, err := secret_model.InsertEncryptedSecret(ctx, tryGetOwnerID(owner), tryGetRepositoryID(repo), name, data) if err != nil { return nil, false, err } + + audit.RecordSecretAdd(ctx, doer, owner, repo, s) + return s, true, nil } - if err := secret_model.UpdateSecret(ctx, s[0].ID, data); err != nil { + s := ss[0] + + if err := secret_model.UpdateSecret(ctx, s.ID, data); err != nil { return nil, false, err } - return s[0], false, nil + audit.RecordSecretUpdate(ctx, doer, owner, repo, s) + + return s, false, nil } -func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) error { +func DeleteSecretByID(ctx context.Context, doer, owner *user_model.User, repo *repo_model.Repository, secretID int64) error { s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ - OwnerID: ownerID, - RepoID: repoID, + OwnerID: tryGetOwnerID(owner), + RepoID: tryGetRepositoryID(repo), SecretID: secretID, }) if err != nil { @@ -52,17 +62,17 @@ func DeleteSecretByID(ctx context.Context, ownerID, repoID, secretID int64) erro return secret_model.ErrSecretNotFound{} } - return deleteSecret(ctx, s[0]) + return deleteSecret(ctx, doer, owner, repo, s[0]) } -func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) error { +func DeleteSecretByName(ctx context.Context, doer, owner *user_model.User, repo *repo_model.Repository, name string) error { if err := ValidateName(name); err != nil { return err } s, err := db.Find[secret_model.Secret](ctx, secret_model.FindSecretsOptions{ - OwnerID: ownerID, - RepoID: repoID, + OwnerID: tryGetOwnerID(owner), + RepoID: tryGetRepositoryID(repo), Name: name, }) if err != nil { @@ -72,12 +82,29 @@ func DeleteSecretByName(ctx context.Context, ownerID, repoID int64, name string) return secret_model.ErrSecretNotFound{} } - return deleteSecret(ctx, s[0]) + return deleteSecret(ctx, doer, owner, repo, s[0]) } -func deleteSecret(ctx context.Context, s *secret_model.Secret) error { +func deleteSecret(ctx context.Context, doer, owner *user_model.User, repo *repo_model.Repository, s *secret_model.Secret) error { if _, err := db.DeleteByID[secret_model.Secret](ctx, s.ID); err != nil { return err } + + audit.RecordSecretRemove(ctx, doer, owner, repo, s) + return nil } + +func tryGetOwnerID(owner *user_model.User) int64 { + if owner == nil { + return 0 + } + return owner.ID +} + +func tryGetRepositoryID(repo *repo_model.Repository) int64 { + if repo == nil { + return 0 + } + return repo.ID +} diff --git a/services/user/block.go b/services/user/block.go index 0b3b618aae670..37c5c9bc62c2c 100644 --- a/services/user/block.go +++ b/services/user/block.go @@ -276,7 +276,7 @@ func removeCollaborations(ctx context.Context, repoOwner, collaborator *user_mod return err } - if err := repo_service.DeleteCollaboration(ctx, repo, collaborator); err != nil { + if err := repo_service.DeleteCollaboration(ctx, repoOwner, repo, collaborator); err != nil { return err } } diff --git a/services/user/email.go b/services/user/email.go index 5c0de708e9a8e..d9c9a51d30614 100644 --- a/services/user/email.go +++ b/services/user/email.go @@ -12,10 +12,11 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/audit" ) // AdminAddOrSetPrimaryEmailAddress is used by admins to add or set a user's primary email address -func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error { +func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, doer, u *user_model.User, emailStr string) error { if strings.EqualFold(u.Email, emailStr) { return nil } @@ -65,10 +66,16 @@ func AdminAddOrSetPrimaryEmailAddress(ctx context.Context, u *user_model.User, e u.Email = emailStr - return user_model.UpdateUserCols(ctx, u, "email") + if err := user_model.UpdateUserCols(ctx, u, "email"); err != nil { + return err + } + + audit.RecordUserEmailPrimaryChange(ctx, doer, u, email) + + return nil } -func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailStr string) error { +func ReplacePrimaryEmailAddress(ctx context.Context, doer, u *user_model.User, emailStr string) error { if strings.EqualFold(u.Email, emailStr) { return nil } @@ -109,15 +116,29 @@ func ReplacePrimaryEmailAddress(ctx context.Context, u *user_model.User, emailSt if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { return err } - } - u.Email = emailStr + u.Email = emailStr - return user_model.UpdateUserCols(ctx, u, "email") + if err := user_model.UpdateUserCols(ctx, u, "email"); err != nil { + return err + } + + audit.RecordUserEmailPrimaryChange(ctx, doer, u, email) + } else { + u.Email = emailStr + + if err := user_model.UpdateUserCols(ctx, u, "email"); err != nil { + return err + } + } + + return nil } -func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error { - for _, emailStr := range emails { +func AddEmailAddresses(ctx context.Context, doer, u *user_model.User, emailsToAdd []string) error { + emails := make([]*user_model.EmailAddress, 0, len(emailsToAdd)) + + for _, emailStr := range emailsToAdd { if err := user_model.ValidateEmail(emailStr); err != nil { return err } @@ -141,13 +162,21 @@ func AddEmailAddresses(ctx context.Context, u *user_model.User, emails []string) if _, err := user_model.InsertEmailAddress(ctx, email); err != nil { return err } + + emails = append(emails, email) + } + + for _, email := range emails { + audit.RecordUserEmailAdd(ctx, doer, u, email) } return nil } -func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []string) error { - for _, emailStr := range emails { +func DeleteEmailAddresses(ctx context.Context, doer, u *user_model.User, emailsToRemove []string) error { + emails := make([]*user_model.EmailAddress, 0, len(emailsToRemove)) + + for _, emailStr := range emailsToRemove { // Check if address exists email, err := user_model.GetEmailAddressOfUser(ctx, emailStr, u.ID) if err != nil { @@ -161,6 +190,12 @@ func DeleteEmailAddresses(ctx context.Context, u *user_model.User, emails []stri if _, err := db.DeleteByID[user_model.EmailAddress](ctx, email.ID); err != nil { return err } + + emails = append(emails, email) + } + + for _, email := range emails { + audit.RecordUserEmailRemove(ctx, doer, u, email) } return nil diff --git a/services/user/email_test.go b/services/user/email_test.go index b40f86b6a68fe..0b4f97159a267 100644 --- a/services/user/email_test.go +++ b/services/user/email_test.go @@ -30,7 +30,7 @@ func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) { assert.NotEqual(t, "new-primary@example.com", primary.Email) assert.Equal(t, user.Email, primary.Email) - assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary@example.com")) + assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, user, "new-primary@example.com")) primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) assert.NoError(t, err) @@ -46,14 +46,14 @@ func TestAdminAddOrSetPrimaryEmailAddress(t *testing.T) { setting.Service.EmailDomainAllowList = []glob.Glob{} }() - assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "new-primary2@example2.com")) + assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, user, "new-primary2@example2.com")) primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) assert.NoError(t, err) assert.Equal(t, "new-primary2@example2.com", primary.Email) assert.Equal(t, user.Email, primary.Email) - assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, "user27@example.com")) + assert.NoError(t, AdminAddOrSetPrimaryEmailAddress(db.DefaultContext, user, user, "user27@example.com")) primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) assert.NoError(t, err) @@ -80,7 +80,7 @@ func TestReplacePrimaryEmailAddress(t *testing.T) { assert.NotEqual(t, "primary-13@example.com", primary.Email) assert.Equal(t, user.Email, primary.Email) - assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com")) + assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, user, "primary-13@example.com")) primary, err = user_model.GetPrimaryEmailAddressOfUser(db.DefaultContext, user.ID) assert.NoError(t, err) @@ -91,15 +91,16 @@ func TestReplacePrimaryEmailAddress(t *testing.T) { assert.NoError(t, err) assert.Len(t, emails, 1) - assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, "primary-13@example.com")) + assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, user, "primary-13@example.com")) }) t.Run("Organization", func(t *testing.T) { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) org := unittest.AssertExistsAndLoadBean(t, &organization_model.Organization{ID: 3}) assert.Equal(t, "org3@example.com", org.Email) - assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, org.AsUser(), "primary-org@example.com")) + assert.NoError(t, ReplacePrimaryEmailAddress(db.DefaultContext, user, org.AsUser(), "primary-org@example.com")) assert.Equal(t, "primary-org@example.com", org.Email) }) @@ -110,13 +111,13 @@ func TestAddEmailAddresses(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) - assert.Error(t, AddEmailAddresses(db.DefaultContext, user, []string{" invalid email "})) + assert.Error(t, AddEmailAddresses(db.DefaultContext, user, user, []string{" invalid email "})) emails := []string{"user1234@example.com", "user5678@example.com"} - assert.NoError(t, AddEmailAddresses(db.DefaultContext, user, emails)) + assert.NoError(t, AddEmailAddresses(db.DefaultContext, user, user, emails)) - err := AddEmailAddresses(db.DefaultContext, user, emails) + err := AddEmailAddresses(db.DefaultContext, user, user, emails) assert.Error(t, err) assert.True(t, user_model.IsErrEmailAlreadyUsed(err)) } @@ -128,16 +129,16 @@ func TestDeleteEmailAddresses(t *testing.T) { emails := []string{"user2-2@example.com"} - err := DeleteEmailAddresses(db.DefaultContext, user, emails) + err := DeleteEmailAddresses(db.DefaultContext, user, user, emails) assert.NoError(t, err) - err = DeleteEmailAddresses(db.DefaultContext, user, emails) + err = DeleteEmailAddresses(db.DefaultContext, user, user, emails) assert.Error(t, err) assert.True(t, user_model.IsErrEmailAddressNotExist(err)) emails = []string{"user2@example.com"} - err = DeleteEmailAddresses(db.DefaultContext, user, emails) + err = DeleteEmailAddresses(db.DefaultContext, user, user, emails) assert.Error(t, err) assert.True(t, user_model.IsErrPrimaryEmailCannotDelete(err)) } diff --git a/services/user/update.go b/services/user/update.go index cbaf90053a229..baefa3c61d946 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/audit" ) type UpdateOptions struct { @@ -39,7 +40,7 @@ type UpdateOptions struct { RepoAdminChangeTeamAccess optional.Option[bool] } -func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { +func UpdateUser(ctx context.Context, doer, u *user_model.User, opts *UpdateOptions) error { cols := make([]string, 0, 20) if opts.KeepEmailPrivate.Has() { @@ -101,12 +102,17 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er cols = append(cols, "max_repo_creation") } + isActiveChanged, isRestrictedChanged, isAdminChanged := false, false, false if opts.IsActive.Has() { + isActiveChanged = u.IsActive != opts.IsActive.Value() + u.IsActive = opts.IsActive.Value() cols = append(cols, "is_active") } if opts.IsRestricted.Has() { + isRestrictedChanged = u.IsActive != opts.IsRestricted.Value() + u.IsRestricted = opts.IsRestricted.Value() cols = append(cols, "is_restricted") @@ -116,15 +122,21 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er return models.ErrDeleteLastAdminUser{UID: u.ID} } + isAdminChanged = u.IsAdmin != opts.IsAdmin.Value() + u.IsAdmin = opts.IsAdmin.Value() cols = append(cols, "is_admin") } + visibilityChanged := false if opts.Visibility.Has() { if !u.IsOrganization() && !setting.Service.AllowedUserVisibilityModesSlice.IsAllowedVisibility(opts.Visibility.Value()) { return fmt.Errorf("visibility mode not allowed: %s", opts.Visibility.Value().String()) } + + visibilityChanged = u.Visibility != opts.Visibility.Value() + u.Visibility = opts.Visibility.Value() cols = append(cols, "visibility") @@ -158,7 +170,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 isActiveChanged { + audit.RecordUserActive(ctx, doer, u) + } + if isAdminChanged { + audit.RecordUserAdmin(ctx, doer, u) + } + if isRestrictedChanged { + audit.RecordUserRestricted(ctx, doer, u) + } + if visibilityChanged { + audit.RecordUserVisibility(ctx, doer, u) + } + + return nil } type UpdateAuthOptions struct { @@ -169,13 +198,16 @@ type UpdateAuthOptions struct { ProhibitLogin optional.Option[bool] } -func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error { +func UpdateAuth(ctx context.Context, doer, u *user_model.User, opts *UpdateAuthOptions) error { + loginSourceChanged := false if opts.LoginSource.Has() { source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value()) if err != nil { return err } + loginSourceChanged = u.LoginSource != source.ID + u.LoginType = source.Type u.LoginSource = source.ID } @@ -183,8 +215,10 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions u.LoginName = opts.LoginName.Value() } - deleteAuthTokens := false + passwordChanged := false if opts.Password.Has() && (u.IsLocal() || u.IsOAuth2()) { + passwordChanged = true + password := opts.Password.Value() if len(password) < setting.MinPasswordLength { @@ -200,8 +234,6 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions if err := u.SetPassword(password); err != nil { return err } - - deleteAuthTokens = true } if opts.MustChangePassword.Has() { @@ -215,8 +247,16 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions return err } - if deleteAuthTokens { + if passwordChanged { return auth_model.DeleteAuthTokensByUserID(ctx, u.ID) } + + if passwordChanged { + audit.RecordUserPassword(ctx, doer, u) + } + if loginSourceChanged { + audit.RecordUserAuthenticationSource(ctx, doer, u) + } + return nil } diff --git a/services/user/update_test.go b/services/user/update_test.go index fc24a6c212107..70540b480f663 100644 --- a/services/user/update_test.go +++ b/services/user/update_test.go @@ -21,7 +21,7 @@ func TestUpdateUser(t *testing.T) { admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - assert.Error(t, UpdateUser(db.DefaultContext, admin, &UpdateOptions{ + assert.Error(t, UpdateUser(db.DefaultContext, admin, admin, &UpdateOptions{ IsAdmin: optional.Some(false), })) @@ -48,7 +48,7 @@ func TestUpdateUser(t *testing.T) { EmailNotificationsPreference: optional.Some("disabled"), SetLastLogin: true, } - assert.NoError(t, UpdateUser(db.DefaultContext, user, opts)) + assert.NoError(t, UpdateUser(db.DefaultContext, user, user, opts)) assert.Equal(t, opts.KeepEmailPrivate.Value(), user.KeepEmailPrivate) assert.Equal(t, opts.FullName.Value(), user.FullName) @@ -96,12 +96,12 @@ func TestUpdateAuth(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) userCopy := *user - assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + assert.NoError(t, UpdateAuth(db.DefaultContext, user, user, &UpdateAuthOptions{ LoginName: optional.Some("new-login"), })) assert.Equal(t, "new-login", user.LoginName) - assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + assert.NoError(t, UpdateAuth(db.DefaultContext, user, user, &UpdateAuthOptions{ Password: optional.Some("%$DRZUVB576tfzgu"), MustChangePassword: optional.Some(true), })) @@ -109,12 +109,12 @@ func TestUpdateAuth(t *testing.T) { assert.NotEqual(t, userCopy.Passwd, user.Passwd) assert.NotEqual(t, userCopy.Salt, user.Salt) - assert.NoError(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + assert.NoError(t, UpdateAuth(db.DefaultContext, user, user, &UpdateAuthOptions{ ProhibitLogin: optional.Some(true), })) assert.True(t, user.ProhibitLogin) - assert.ErrorIs(t, UpdateAuth(db.DefaultContext, user, &UpdateAuthOptions{ + assert.ErrorIs(t, UpdateAuth(db.DefaultContext, user, user, &UpdateAuthOptions{ Password: optional.Some("aaaa"), }), password_module.ErrMinLength) } diff --git a/services/user/user.go b/services/user/user.go index 7bde642412be0..93818ce0c6497 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/agit" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" org_service "code.gitea.io/gitea/services/org" "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" @@ -31,7 +32,7 @@ import ( ) // RenameUser renames a user -func RenameUser(ctx context.Context, u *user_model.User, newUserName string) error { +func RenameUser(ctx context.Context, doer, u *user_model.User, newUserName string) error { if newUserName == u.Name { return nil } @@ -115,13 +116,16 @@ func RenameUser(ctx context.Context, u *user_model.User, newUserName string) err } return err } + + audit.RecordUserName(ctx, doer, u) + return nil } // 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. -func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { +func DeleteUser(ctx context.Context, doer, u *user_model.User, purge bool) error { if u.IsOrganization() { return fmt.Errorf("%s is an organization not a user", u.Name) } @@ -190,7 +194,7 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { for _, org := range orgs { if err := org_service.RemoveOrgUser(ctx, org, u); err != nil { if organization.IsErrLastOrgOwner(err) { - err = org_service.DeleteOrganization(ctx, org, true) + err = org_service.DeleteOrganization(ctx, doer, org, true) if err != nil { return fmt.Errorf("unable to delete organization %d: %w", org.ID, err) } @@ -274,11 +278,13 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { } } + audit.RecordUserDelete(ctx, doer, u) + return nil } // DeleteInactiveUsers deletes all inactive users and their email addresses. -func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { +func DeleteInactiveUsers(ctx context.Context, doer *user_model.User, olderThan time.Duration) error { inactiveUsers, err := user_model.GetInactiveUsers(ctx, olderThan) if err != nil { return err @@ -286,7 +292,7 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error { // FIXME: should only update authorized_keys file once after all deletions. for _, u := range inactiveUsers { - if err = DeleteUser(ctx, u, false); err != nil { + if err = DeleteUser(ctx, doer, u, false); err != nil { // Ignore inactive users that were ever active but then were set inactive by admin if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || models.IsErrUserOwnPackages(err) { log.Warn("Inactive user %q has repositories, organizations or packages, skipping deletion: %v", u.Name, err) diff --git a/services/user/user_test.go b/services/user/user_test.go index c668b005c57e9..4b6f9a62bb7b2 100644 --- a/services/user/user_test.go +++ b/services/user/user_test.go @@ -35,7 +35,7 @@ func TestDeleteUser(t *testing.T) { ownedRepos := make([]*repo_model.Repository, 0, 10) assert.NoError(t, db.GetEngine(db.DefaultContext).Find(&ownedRepos, &repo_model.Repository{OwnerID: userID})) if len(ownedRepos) > 0 { - err := DeleteUser(db.DefaultContext, user, false) + err := DeleteUser(db.DefaultContext, user, user, false) assert.Error(t, err) assert.True(t, models.IsErrUserOwnRepos(err)) return @@ -50,7 +50,7 @@ func TestDeleteUser(t *testing.T) { return } } - assert.NoError(t, DeleteUser(db.DefaultContext, user, false)) + assert.NoError(t, DeleteUser(db.DefaultContext, user, user, false)) unittest.AssertNotExistsBean(t, &user_model.User{ID: userID}) unittest.CheckConsistencyFor(t, &user_model.User{}, &repo_model.Repository{}) } @@ -60,7 +60,7 @@ func TestDeleteUser(t *testing.T) { test(11) org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) - assert.Error(t, DeleteUser(db.DefaultContext, org, false)) + assert.Error(t, DeleteUser(db.DefaultContext, org, org, false)) } func TestPurgeUser(t *testing.T) { @@ -68,7 +68,7 @@ func TestPurgeUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID}) - err := DeleteUser(db.DefaultContext, user, true) + err := DeleteUser(db.DefaultContext, user, user, true) assert.NoError(t, err) unittest.AssertNotExistsBean(t, &user_model.User{ID: userID}) @@ -80,7 +80,7 @@ func TestPurgeUser(t *testing.T) { test(11) org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) - assert.Error(t, DeleteUser(db.DefaultContext, org, false)) + assert.Error(t, DeleteUser(db.DefaultContext, org, org, false)) } func TestCreateUser(t *testing.T) { @@ -95,7 +95,7 @@ func TestCreateUser(t *testing.T) { assert.NoError(t, user_model.CreateUser(db.DefaultContext, user, &user_model.Meta{})) - assert.NoError(t, DeleteUser(db.DefaultContext, user, false)) + assert.NoError(t, DeleteUser(db.DefaultContext, user, user, false)) } func TestRenameUser(t *testing.T) { @@ -107,18 +107,18 @@ func TestRenameUser(t *testing.T) { Type: user_model.UserTypeIndividual, LoginType: auth.OAuth2, } - assert.ErrorIs(t, RenameUser(db.DefaultContext, u, "user_rename"), user_model.ErrUserIsNotLocal{}) + assert.ErrorIs(t, RenameUser(db.DefaultContext, user, u, "user_rename"), user_model.ErrUserIsNotLocal{}) }) t.Run("Same username", func(t *testing.T) { - assert.NoError(t, RenameUser(db.DefaultContext, user, user.Name)) + assert.NoError(t, RenameUser(db.DefaultContext, user, user, user.Name)) }) t.Run("Non usable username", func(t *testing.T) { usernames := []string{"--diff", ".well-known", "gitea-actions", "aaa.atom", "aa.png"} for _, username := range usernames { assert.Error(t, user_model.IsUsableUsername(username), "non-usable username: %s", username) - assert.Error(t, RenameUser(db.DefaultContext, user, username), "non-usable username: %s", username) + assert.Error(t, RenameUser(db.DefaultContext, user, user, username), "non-usable username: %s", username) } }) @@ -127,7 +127,7 @@ func TestRenameUser(t *testing.T) { unittest.AssertNotExistsBean(t, &user_model.User{ID: user.ID, Name: caps}) unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: user.Name}) - assert.NoError(t, RenameUser(db.DefaultContext, user, caps)) + assert.NoError(t, RenameUser(db.DefaultContext, user, user, caps)) unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: caps}) unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, OwnerName: caps}) @@ -136,17 +136,17 @@ func TestRenameUser(t *testing.T) { t.Run("Already exists", func(t *testing.T) { existUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) - assert.ErrorIs(t, RenameUser(db.DefaultContext, user, existUser.Name), user_model.ErrUserAlreadyExist{Name: existUser.Name}) - assert.ErrorIs(t, RenameUser(db.DefaultContext, user, existUser.LowerName), user_model.ErrUserAlreadyExist{Name: existUser.LowerName}) + assert.ErrorIs(t, RenameUser(db.DefaultContext, user, user, existUser.Name), user_model.ErrUserAlreadyExist{Name: existUser.Name}) + assert.ErrorIs(t, RenameUser(db.DefaultContext, user, user, existUser.LowerName), user_model.ErrUserAlreadyExist{Name: existUser.LowerName}) newUsername := fmt.Sprintf("uSEr%d", existUser.ID) - assert.ErrorIs(t, RenameUser(db.DefaultContext, user, newUsername), user_model.ErrUserAlreadyExist{Name: newUsername}) + assert.ErrorIs(t, RenameUser(db.DefaultContext, user, user, newUsername), user_model.ErrUserAlreadyExist{Name: newUsername}) }) t.Run("Normal", func(t *testing.T) { oldUsername := user.Name newUsername := "User_Rename" - assert.NoError(t, RenameUser(db.DefaultContext, user, newUsername)) + assert.NoError(t, RenameUser(db.DefaultContext, user, user, newUsername)) unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID, Name: newUsername, LowerName: strings.ToLower(newUsername)}) redirectUID, err := user_model.LookupUserRedirect(db.DefaultContext, oldUsername) @@ -183,7 +183,7 @@ func TestCreateUser_Issue5882(t *testing.T) { assert.Equal(t, !u.AllowCreateOrganization, v.disableOrgCreation) - assert.NoError(t, DeleteUser(db.DefaultContext, v.user, false)) + assert.NoError(t, DeleteUser(db.DefaultContext, v.user, v.user, false)) } } @@ -202,7 +202,7 @@ func TestDeleteInactiveUsers(t *testing.T) { addUser("user-active-5", "user-active-5@test.com", timeutil.TimeStampNow().Add(-300), true) unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-10"}) unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"}) - assert.NoError(t, DeleteInactiveUsers(db.DefaultContext, 8*time.Minute)) + assert.NoError(t, DeleteInactiveUsers(db.DefaultContext, user_model.NewGhostUser(), 8*time.Minute)) unittest.AssertNotExistsBean(t, &user_model.User{Name: "user-inactive-10"}) unittest.AssertNotExistsBean(t, &user_model.EmailAddress{Email: "user-inactive-10@test.com"}) unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user-inactive-5"}) diff --git a/services/webhook/main_test.go b/services/webhook/main_test.go index 756b9db23083c..35bb617b52b8d 100644 --- a/services/webhook/main_test.go +++ b/services/webhook/main_test.go @@ -18,9 +18,6 @@ func TestMain(m *testing.M) { // for tests, allow only loopback IPs setting.Webhook.AllowedHostList = hostmatcher.MatchBuiltinLoopback unittest.MainTest(m, &unittest.TestOptions{ - SetUp: func() error { - setting.LoadQueueSettings() - return Init() - }, + SetUp: Init, }) } diff --git a/services/wiki/wiki.go b/services/wiki/wiki.go index 7a0419aea7c7e..791a8cebf99a3 100644 --- a/services/wiki/wiki.go +++ b/services/wiki/wiki.go @@ -23,6 +23,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/util" asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/audit" repo_service "code.gitea.io/gitea/services/repository" ) @@ -358,12 +359,15 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model } // DeleteWiki removes the actual and local copy of repository wiki. -func DeleteWiki(ctx context.Context, repo *repo_model.Repository) error { +func DeleteWiki(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) error { if err := repo_service.UpdateRepositoryUnits(ctx, repo, nil, []unit.Type{unit.TypeWiki}); err != nil { return err } system_model.RemoveAllWithNotice(ctx, "Delete repository wiki", repo.WikiPath()) + + audit.RecordRepositoryWikiDelete(ctx, doer, repo) + return nil } diff --git a/templates/admin/audit/list.tmpl b/templates/admin/audit/list.tmpl new file mode 100644 index 0000000000000..e965ecaeb513b --- /dev/null +++ b/templates/admin/audit/list.tmpl @@ -0,0 +1,6 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin monitor")}} +
{{ctx.Locale.Tr "admin.monitor.audit.actor"}} | +{{ctx.Locale.Tr "admin.monitor.audit.scope"}} | +{{ctx.Locale.Tr "admin.monitor.audit.action"}} | +{{ctx.Locale.Tr "admin.monitor.audit.target"}} | +{{ctx.Locale.Tr "admin.monitor.audit.ip_address"}} | +{{ctx.Locale.Tr "admin.monitor.audit.timestamp"}}{{SortArrow "timestamp_asc" "timestamp_desc" $.AuditSort true}} | +
---|---|---|---|---|---|
+ {{if .Actor.Object}} + {{if gt .Actor.ID 0}} + {{.Actor.DisplayName}} + {{else}} + {{.Actor.DisplayName}} + {{end}} + {{else}} + {{ctx.Locale.Tr "admin.monitor.audit.deleted.actor"}} + {{end}} + | ++ {{if or .Scope.Object (eq .Scope.Type "system")}} + {{$url := .Scope.HTMLURL}} + {{if $url}} + {{.Scope.DisplayName}} + {{else}} + {{.Scope.DisplayName}} + {{end}} + {{else}} + {{ctx.Locale.Tr "admin.monitor.audit.deleted.type" .Scope.Type .Scope.ID}} + {{end}} + | +{{.Message}} | ++ {{if or .Target.Object (eq .Scope.Type "system")}} + {{$url := .Target.HTMLURL}} + {{if $url}} + {{.Target.DisplayName}} + {{else}} + {{.Target.DisplayName}} + {{end}} + {{else}} + {{ctx.Locale.Tr "admin.monitor.audit.deleted.type" .Target.Type .Target.ID}} + {{end}} + | +{{.IPAddress}} | +{{DateTime "full" .Time}} | +
{{ctx.Locale.Tr "admin.monitor.audit.no_events"}} |