mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-20 19:53:41 +09:00
feat: Add audit logging (#38189)
Co-authored-by: bircni <bircni@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
co-authored by
bircni
wxiaoguang
parent
4d43445532
commit
da37b7916b
@@ -54,6 +54,9 @@ func newAuthCommand() *cli.Command {
|
|||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: "auth",
|
Name: "auth",
|
||||||
Usage: "Modify external auth providers",
|
Usage: "Modify external auth providers",
|
||||||
|
Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) {
|
||||||
|
return cliAuditContext(ctx), nil
|
||||||
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
microcmdAuthAddOauth(),
|
microcmdAuthAddOauth(),
|
||||||
microcmdAuthUpdateOauth(),
|
microcmdAuthUpdateOauth(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
auth_service "gitea.dev/services/auth"
|
||||||
"gitea.dev/services/auth/source/ldap"
|
"gitea.dev/services/auth/source/ldap"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
@@ -221,8 +222,8 @@ func microcmdAuthUpdateLdapSimpleAuth() *cli.Command {
|
|||||||
func newAuthService() *authService {
|
func newAuthService() *authService {
|
||||||
return &authService{
|
return &authService{
|
||||||
initDB: initDB,
|
initDB: initDB,
|
||||||
createAuthSource: auth.CreateSource,
|
createAuthSource: auth_service.CreateSource,
|
||||||
updateAuthSource: auth.UpdateSource,
|
updateAuthSource: auth_service.UpdateSource,
|
||||||
getAuthSourceByID: auth.GetSourceByID,
|
getAuthSourceByID: auth.GetSourceByID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +13,9 @@ func newUserCommand() *cli.Command {
|
|||||||
return &cli.Command{
|
return &cli.Command{
|
||||||
Name: "user",
|
Name: "user",
|
||||||
Usage: "Modify users",
|
Usage: "Modify users",
|
||||||
|
Before: func(ctx context.Context, _ *cli.Command) (context.Context, error) {
|
||||||
|
return cliAuditContext(ctx), nil
|
||||||
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
microcmdUserCreate(),
|
microcmdUserCreate(),
|
||||||
newUserListCommand(),
|
newUserListCommand(),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cliAuditContext(ctx context.Context) context.Context {
|
||||||
|
ctx = audit.WithOrigin(ctx, audit_model.OriginCLI)
|
||||||
|
return audit.WithDoer(ctx, user_model.NewCLIUser())
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/modules/container"
|
"gitea.dev/modules/container"
|
||||||
"gitea.dev/modules/graceful"
|
"gitea.dev/modules/graceful"
|
||||||
"gitea.dev/modules/gtprof"
|
"gitea.dev/modules/gtprof"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/routers"
|
"gitea.dev/routers"
|
||||||
"gitea.dev/routers/install"
|
"gitea.dev/routers/install"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
|
|
||||||
"github.com/felixge/fgprof"
|
"github.com/felixge/fgprof"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
@@ -226,7 +228,14 @@ func serveInstalled(c *cli.Command) error {
|
|||||||
|
|
||||||
// Set up Chi routes
|
// Set up Chi routes
|
||||||
webRoutes := routers.NormalRoutes()
|
webRoutes := routers.NormalRoutes()
|
||||||
|
|
||||||
|
auditCtx := cliAuditContext(context.Background())
|
||||||
|
log.Info("Audit record output: %s", setting.Audit.RecordOutput)
|
||||||
|
audit.Record(auditCtx, audit_model.SystemStartup, nil, "version", setting.AppVer)
|
||||||
|
|
||||||
err := listen(webRoutes, true)
|
err := listen(webRoutes, true)
|
||||||
|
|
||||||
|
audit.Record(auditCtx, audit_model.SystemShutdown, nil)
|
||||||
<-graceful.GetManager().Done()
|
<-graceful.GetManager().Done()
|
||||||
log.Info("PID: %d Gitea Web Finished", os.Getpid())
|
log.Info("PID: %d Gitea Web Finished", os.Getpid())
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -545,6 +545,20 @@ INTERNAL_TOKEN =
|
|||||||
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
|
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
|
||||||
;ALLOWED_HOST_LIST = external
|
;ALLOWED_HOST_LIST = external
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;[audit]
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Where security relevant events are recorded: "disabled" or "database".
|
||||||
|
;; With "database" the events are shown in the admin, organization, repository
|
||||||
|
;; and user settings.
|
||||||
|
;RECORD_OUTPUT = disabled
|
||||||
|
;;
|
||||||
|
;; Days to keep recorded events, 0 keeps them forever. Pruning is done by the
|
||||||
|
;; "cron.delete_old_audit_events" task.
|
||||||
|
;RETENTION_DAYS = 30
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
[camo]
|
[camo]
|
||||||
@@ -2504,6 +2518,21 @@ LEVEL = Info
|
|||||||
;SCHEDULE = @every 168h
|
;SCHEDULE = @every 168h
|
||||||
;OLDER_THAN = 8760h
|
;OLDER_THAN = 8760h
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Delete audit events which are older than the retention period.
|
||||||
|
;; Only registered when [audit].RECORD_OUTPUT records events and RETENTION_DAYS is set.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;[cron.delete_old_audit_events]
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;ENABLED = true
|
||||||
|
;RUN_AT_START = false
|
||||||
|
;NOTICE_ON_SUCCESS = false
|
||||||
|
;SCHEDULE = @every 24h
|
||||||
|
;; Defaults to [audit].RETENTION_DAYS
|
||||||
|
;OLDER_THAN = 720h
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; Check for new Gitea versions
|
;; Check for new Gitea versions
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease),
|
newMigration(350, "Add published_unix column to release", v28.AddPublishedUnixToRelease),
|
||||||
newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer),
|
newMigration(351, "Track transfer recipient access grants", v28.AddRecipientAccessGrantedToRepoTransfer),
|
||||||
newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey),
|
newMigration(352, "Add token columns to deploy_key", v28.AddTokenToDeployKey),
|
||||||
|
newMigration(353, "Add audit event table", v28.AddAuditEventTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v28
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.dev/modelmigration/base"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditEvent struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
Action string `xorm:"INDEX NOT NULL"`
|
||||||
|
ActorID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
ActorName string
|
||||||
|
ActorCredential string
|
||||||
|
ImpersonatorID int64 `xorm:"INDEX"`
|
||||||
|
ImpersonatorName string
|
||||||
|
ScopeID int64 `xorm:"INDEX(scope) NOT NULL"`
|
||||||
|
ScopeType string `xorm:"INDEX INDEX(scope) NOT NULL"`
|
||||||
|
ScopeName string
|
||||||
|
Origin string `xorm:"INDEX NOT NULL"`
|
||||||
|
Message string
|
||||||
|
Metadata string `xorm:"LONGTEXT JSON"`
|
||||||
|
IPAddress string
|
||||||
|
TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*AuditEvent) TableName() string {
|
||||||
|
return "audit_event"
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddAuditEventTable(_ context.Context, x base.EngineMigration) error {
|
||||||
|
return x.Sync(new(AuditEvent))
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v28
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modelmigration/migrationtest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"xorm.io/xorm/schemas"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAddAuditEventTable(t *testing.T) {
|
||||||
|
x, deferable := migrationtest.PrepareTestEnv(t, 0)
|
||||||
|
defer deferable()
|
||||||
|
if x == nil || t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, AddAuditEventTable(t.Context(), x))
|
||||||
|
|
||||||
|
indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "audit_event")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, columns := range [][]string{
|
||||||
|
{"action"},
|
||||||
|
{"actor_id"},
|
||||||
|
{"scope_id", "scope_type"},
|
||||||
|
{"scope_type"},
|
||||||
|
{"origin"},
|
||||||
|
{"timestamp_unix"},
|
||||||
|
} {
|
||||||
|
assert.True(t, hasAuditIndexWithColumns(indexes, columns), "missing index on %v", columns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAuditIndexWithColumns(indexes map[string]*schemas.Index, columns []string) bool {
|
||||||
|
for _, index := range indexes {
|
||||||
|
if slices.Equal(index.Cols, columns) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Action string
|
||||||
|
|
||||||
|
var (
|
||||||
|
actionMessages = map[Action]string{}
|
||||||
|
allActions []Action
|
||||||
|
)
|
||||||
|
|
||||||
|
func define(id, message string) Action {
|
||||||
|
a := Action(id)
|
||||||
|
if _, exists := actionMessages[a]; exists {
|
||||||
|
panic("duplicate audit action: " + id)
|
||||||
|
}
|
||||||
|
actionMessages[a] = message
|
||||||
|
allActions = append(allActions, a)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageTemplate returns the message template registered for an action.
|
||||||
|
func MessageTemplate(a Action) (string, bool) {
|
||||||
|
m, ok := actionMessages[a]
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllActions returns every registered action.
|
||||||
|
func AllActions() []Action {
|
||||||
|
return allActions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionFilters returns every exact action and its selectable hierarchy prefixes.
|
||||||
|
// A prefix is useful when an operator wants every event in a family, such as
|
||||||
|
// user:impersonation, without having to download and post-process the log.
|
||||||
|
func ActionFilters() []Action {
|
||||||
|
filters := make(map[Action]struct{}, len(allActions)*2)
|
||||||
|
for _, action := range allActions {
|
||||||
|
filters[action] = struct{}{}
|
||||||
|
parts := strings.Split(string(action), ":")
|
||||||
|
for i := 2; i < len(parts); i++ {
|
||||||
|
filters[Action(strings.Join(parts[:i], ":"))] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]Action, 0, len(filters))
|
||||||
|
for action := range filters {
|
||||||
|
result = append(result, action)
|
||||||
|
}
|
||||||
|
slices.Sort(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActionFilter reports whether action is an exact action or a family prefix.
|
||||||
|
func IsActionFilter(action Action) bool {
|
||||||
|
return slices.Contains(ActionFilters(), action)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
UserImpersonation = define("user:impersonation:start", "User {actor} started impersonating user {scope}.")
|
||||||
|
UserImpersonationExit = define("user:impersonation:exit", "User {actor} stopped impersonating user {scope}.")
|
||||||
|
UserCreate = define("user:create", "Created user {scope}.")
|
||||||
|
UserDelete = define("user:delete", "Deleted user {scope}.")
|
||||||
|
UserAuthenticationFailTwoFactor = define("user:authentication:fail:twofactor", "Failed two-factor authentication for user {scope}.")
|
||||||
|
UserAuthenticationSource = define("user:authentication:source:update", "Changed authentication source of user {scope} to {auth_source}.")
|
||||||
|
UserActive = define("user:status:active", "Changed activation status of user {scope} to {active}.")
|
||||||
|
UserRestricted = define("user:status:restricted", "Changed restricted status of user {scope} to {restricted}.")
|
||||||
|
UserAdmin = define("user:status:admin", "Changed admin status of user {scope} to {admin}.")
|
||||||
|
UserName = define("user:name:update", "Changed user name from {previous_name} to {scope}.")
|
||||||
|
UserPassword = define("user:password", "Changed password of user {scope}.")
|
||||||
|
UserPasswordResetRequest = define("user:password:resetrequest", "Requested password reset for user {scope}.")
|
||||||
|
UserVisibility = define("user:visibility:update", "Changed visibility of user {scope} from {old_visibility} to {new_visibility}.")
|
||||||
|
UserEmailPrimaryChange = define("user:email:primary", "Changed primary email of user {scope} to {email}.")
|
||||||
|
UserEmailAdd = define("user:email:add", "Added email {email} to user {scope}.")
|
||||||
|
UserEmailActivate = define("user:email:activate", "Changed activation status of email {email} of user {scope}.")
|
||||||
|
UserEmailRemove = define("user:email:remove", "Removed email {email} from user {scope}.")
|
||||||
|
UserTwoFactorEnable = define("user:twofactor:enable", "Enabled two-factor authentication for user {scope}.")
|
||||||
|
UserTwoFactorRegenerate = define("user:twofactor:regenerate", "Regenerated two-factor authentication secret for user {scope}.")
|
||||||
|
UserTwoFactorDisable = define("user:twofactor:disable", "Disabled two-factor authentication for user {scope}.")
|
||||||
|
UserWebAuthAdd = define("user:webauth:add", "Added WebAuthn key {credential} for user {scope}.")
|
||||||
|
UserWebAuthRemove = define("user:webauth:remove", "Removed WebAuthn key {credential} from user {scope}.")
|
||||||
|
UserExternalLoginAdd = define("user:externallogin:add", "Added external login {external_id} for user {scope} using provider {provider}.")
|
||||||
|
UserExternalLoginRemove = define("user:externallogin:remove", "Removed external login from authentication source {auth_source_id} for user {scope}.")
|
||||||
|
UserOpenIDAdd = define("user:openid:add", "Associated OpenID {openid} to user {scope}.")
|
||||||
|
UserOpenIDRemove = define("user:openid:remove", "Removed OpenID {openid} from user {scope}.")
|
||||||
|
UserAccessTokenAdd = define("user:accesstoken:add", "Added access token {token} for user {scope} with scope {token_scope}.")
|
||||||
|
UserAccessTokenRemove = define("user:accesstoken:remove", "Removed access token {token} from user {scope}.")
|
||||||
|
UserOAuth2ApplicationAdd = define("user:oauth2application:add", "Added OAuth2 application {oauth2_application} for user {scope}.")
|
||||||
|
UserOAuth2ApplicationUpdate = define("user:oauth2application:update", "Updated OAuth2 application {oauth2_application} of user {scope}.")
|
||||||
|
UserOAuth2ApplicationSecret = define("user:oauth2application:secret", "Regenerated secret for OAuth2 application {oauth2_application} of user {scope}.")
|
||||||
|
UserOAuth2ApplicationGrant = define("user:oauth2application:grant", "Granted OAuth2 application {oauth2_application} access to user {scope}.")
|
||||||
|
UserOAuth2ApplicationRevoke = define("user:oauth2application:revoke", "Revoked OAuth2 grant for application {oauth2_application} of user {scope}.")
|
||||||
|
UserOAuth2ApplicationRemove = define("user:oauth2application:remove", "Removed OAuth2 application {oauth2_application} of user {scope}.")
|
||||||
|
UserKeySSHAdd = define("user:key:ssh:add", "Added SSH key {fingerprint} for user {scope}.")
|
||||||
|
UserKeySSHRemove = define("user:key:ssh:remove", "Removed SSH key {fingerprint} of user {scope}.")
|
||||||
|
UserKeyPrincipalAdd = define("user:key:principal:add", "Added principal key {key} for user {scope}.")
|
||||||
|
UserKeyPrincipalRemove = define("user:key:principal:remove", "Removed principal key {key} of user {scope}.")
|
||||||
|
UserKeyGPGAdd = define("user:key:gpg:add", "Added GPG key {gpg_key_id} for user {scope}.")
|
||||||
|
UserKeyGPGRemove = define("user:key:gpg:remove", "Removed GPG key {gpg_key_id} of user {scope}.")
|
||||||
|
UserSecretAdd = define("user:secret:add", "Added secret {secret} to user {scope}.")
|
||||||
|
UserSecretUpdate = define("user:secret:update", "Updated secret {secret} of user {scope}.")
|
||||||
|
UserSecretRemove = define("user:secret:remove", "Removed secret {secret} from user {scope}.")
|
||||||
|
UserWebhookAdd = define("user:webhook:add", "Added webhook {webhook} to user {scope}.")
|
||||||
|
UserWebhookUpdate = define("user:webhook:update", "Updated webhook {webhook} of user {scope}.")
|
||||||
|
UserWebhookRemove = define("user:webhook:remove", "Removed webhook {webhook} of user {scope}.")
|
||||||
|
|
||||||
|
OrganizationCreate = define("organization:create", "Created organization {scope}.")
|
||||||
|
OrganizationDelete = define("organization:delete", "Deleted organization {scope}.")
|
||||||
|
OrganizationName = define("organization:name:update", "Changed organization name from {previous_name} to {scope}.")
|
||||||
|
OrganizationVisibility = define("organization:visibility", "Changed visibility of organization {scope} to {new_visibility}.")
|
||||||
|
OrganizationMemberRemove = define("organization:member:remove", "Removed user {member} from organization {scope}.")
|
||||||
|
OrganizationTeamAdd = define("organization:team:add", "Added team {team} to organization {scope}.")
|
||||||
|
OrganizationTeamUpdate = define("organization:team:update", "Updated settings of team {scope}/{team}.")
|
||||||
|
OrganizationTeamRemove = define("organization:team:remove", "Removed team {team} from organization {scope}.")
|
||||||
|
OrganizationTeamPermission = define("organization:team:permission", "Changed permission of team {scope}/{team} to {permission}.")
|
||||||
|
OrganizationTeamMemberAdd = define("organization:team:member:add", "Added user {member} to team {scope}/{team}.")
|
||||||
|
OrganizationTeamMemberRemove = define("organization:team:member:remove", "Removed user {member} from team {scope}/{team}.")
|
||||||
|
OrganizationOAuth2ApplicationAdd = define("organization:oauth2application:add", "Added OAuth2 application {oauth2_application} for organization {scope}.")
|
||||||
|
OrganizationOAuth2ApplicationUpdate = define("organization:oauth2application:update", "Updated OAuth2 application {oauth2_application} of organization {scope}.")
|
||||||
|
OrganizationOAuth2ApplicationSecret = define("organization:oauth2application:secret", "Regenerated secret for OAuth2 application {oauth2_application} of organization {scope}.")
|
||||||
|
OrganizationOAuth2ApplicationRemove = define("organization:oauth2application:remove", "Removed OAuth2 application {oauth2_application} of organization {scope}.")
|
||||||
|
OrganizationSecretAdd = define("organization:secret:add", "Added secret {secret} to organization {scope}.")
|
||||||
|
OrganizationSecretUpdate = define("organization:secret:update", "Updated secret {secret} of organization {scope}.")
|
||||||
|
OrganizationSecretRemove = define("organization:secret:remove", "Removed secret {secret} from organization {scope}.")
|
||||||
|
OrganizationWebhookAdd = define("organization:webhook:add", "Added webhook {webhook} to organization {scope}.")
|
||||||
|
OrganizationWebhookUpdate = define("organization:webhook:update", "Updated webhook {webhook} of organization {scope}.")
|
||||||
|
OrganizationWebhookRemove = define("organization:webhook:remove", "Removed webhook {webhook} of organization {scope}.")
|
||||||
|
|
||||||
|
RepositoryCreate = define("repository:create", "Created repository {scope}.")
|
||||||
|
RepositoryCreateFork = define("repository:fork:create", "Created fork {scope} of repository {base_repo}.")
|
||||||
|
RepositoryArchive = define("repository:archive", "Archived repository {scope}.")
|
||||||
|
RepositoryUnarchive = define("repository:unarchive", "Unarchived repository {scope}.")
|
||||||
|
RepositoryDelete = define("repository:delete", "Deleted repository {scope}.")
|
||||||
|
RepositoryName = define("repository:name:update", "Changed repository name from {previous_name} to {scope}.")
|
||||||
|
RepositoryVisibility = define("repository:visibility:update", "Changed visibility of repository {scope} to {visibility}.")
|
||||||
|
RepositoryConvertFork = define("repository:fork:convert", "Converted repository {scope} from fork to regular repository.")
|
||||||
|
RepositoryConvertMirror = define("repository:mirror:convert", "Converted repository {scope} from pull mirror to regular repository.")
|
||||||
|
RepositoryMirrorPushAdd = define("repository:mirror:push:add", "Added push mirror to {remote_address} for repository {scope}.")
|
||||||
|
RepositoryMirrorPushRemove = define("repository:mirror:push:remove", "Removed push mirror to {remote_address} for repository {scope}.")
|
||||||
|
RepositorySigningVerification = define("repository:signingverification", "Changed signing verification of repository {scope} to {trust_model}.")
|
||||||
|
RepositoryTransferStart = define("repository:transfer:start", "Started repository transfer of {scope} to {new_owner}.")
|
||||||
|
RepositoryTransferFinish = define("repository:transfer:finish", "Transferred repository {scope} from {old_owner} to {new_owner}.")
|
||||||
|
RepositoryTransferCancel = define("repository:transfer:cancel", "Canceled transfer of repository {scope}.")
|
||||||
|
RepositoryWikiDelete = define("repository:wiki:delete", "Deleted wiki of repository {scope}.")
|
||||||
|
RepositoryCollaboratorAdd = define("repository:collaborator:add", "Added user {collaborator} as collaborator for repository {scope} with access mode {access_mode}.")
|
||||||
|
RepositoryCollaboratorAccess = define("repository:collaborator:access", "Changed access mode of collaborator {collaborator} of repository {scope} to {access_mode}.")
|
||||||
|
RepositoryCollaboratorRemove = define("repository:collaborator:remove", "Removed collaborator {collaborator} from repository {scope}.")
|
||||||
|
RepositoryCollaboratorTeamAdd = define("repository:collaborator:team:add", "Added team {team} as collaborator for repository {scope}.")
|
||||||
|
RepositoryCollaboratorTeamRemove = define("repository:collaborator:team:remove", "Removed team {team} as collaborator from repository {scope}.")
|
||||||
|
RepositoryBranchDefault = define("repository:branch:default", "Changed default branch of repository {scope} to {default_branch}.")
|
||||||
|
RepositoryBranchProtectionAdd = define("repository:branch:protection:add", "Added branch protection {rule} for repository {scope}.")
|
||||||
|
RepositoryBranchProtectionUpdate = define("repository:branch:protection:update", "Updated branch protection {rule} for repository {scope}.")
|
||||||
|
RepositoryBranchProtectionRemove = define("repository:branch:protection:remove", "Removed branch protection {rule} from repository {scope}.")
|
||||||
|
RepositoryTagProtectionAdd = define("repository:tag:protection:add", "Added tag protection {pattern} for repository {scope}.")
|
||||||
|
RepositoryTagProtectionUpdate = define("repository:tag:protection:update", "Updated tag protection {pattern} for repository {scope}.")
|
||||||
|
RepositoryTagProtectionRemove = define("repository:tag:protection:remove", "Removed tag protection {pattern} from repository {scope}.")
|
||||||
|
RepositoryWebhookAdd = define("repository:webhook:add", "Added webhook {webhook} to repository {scope}.")
|
||||||
|
RepositoryWebhookUpdate = define("repository:webhook:update", "Updated webhook {webhook} of repository {scope}.")
|
||||||
|
RepositoryWebhookRemove = define("repository:webhook:remove", "Removed webhook {webhook} of repository {scope}.")
|
||||||
|
RepositoryDeployKeyAdd = define("repository:deploykey:add", "Added deploy key {deploy_key} for repository {scope}.")
|
||||||
|
RepositoryDeployKeyRemove = define("repository:deploykey:remove", "Removed deploy key {deploy_key} from repository {scope}.")
|
||||||
|
RepositorySecretAdd = define("repository:secret:add", "Added secret {secret} to repository {scope}.")
|
||||||
|
RepositorySecretUpdate = define("repository:secret:update", "Updated secret {secret} of repository {scope}.")
|
||||||
|
RepositorySecretRemove = define("repository:secret:remove", "Removed secret {secret} from repository {scope}.")
|
||||||
|
|
||||||
|
IssueCreate = define("issue:create", "Created issue {issue} in repository {scope}.")
|
||||||
|
IssueDelete = define("issue:delete", "Deleted issue {issue} from repository {scope}.")
|
||||||
|
IssueCommentCreate = define("issue:comment:create", "Added comment {comment_id} to issue {issue} in repository {scope}.")
|
||||||
|
IssueCommentDelete = define("issue:comment:delete", "Deleted comment {comment_id} from issue {issue} in repository {scope}.")
|
||||||
|
|
||||||
|
PullRequestCreate = define("pr:create", "Created pull request {pull_request} in repository {scope}.")
|
||||||
|
PullRequestDelete = define("pr:delete", "Deleted pull request {pull_request} from repository {scope}.")
|
||||||
|
PullRequestMerge = define("pr:merge", "Merged pull request {pull_request} in repository {scope}.")
|
||||||
|
PullRequestCommentCreate = define("pr:comment:create", "Added comment {comment_id} to pull request {pull_request} in repository {scope}.")
|
||||||
|
PullRequestCommentDelete = define("pr:comment:delete", "Deleted comment {comment_id} from pull request {pull_request} in repository {scope}.")
|
||||||
|
|
||||||
|
ProjectCreate = define("project:create", "Created project {project} in {scope}.")
|
||||||
|
ProjectUpdate = define("project:update", "Updated project {project} in {scope}.")
|
||||||
|
ProjectDelete = define("project:delete", "Deleted project {project} from {scope}.")
|
||||||
|
|
||||||
|
WikiPageCreate = define("wiki:page:create", "Created wiki page {page} in repository {scope}.")
|
||||||
|
WikiPageUpdate = define("wiki:page:update", "Updated wiki page {page} in repository {scope}.")
|
||||||
|
WikiPageDelete = define("wiki:page:delete", "Deleted wiki page {page} from repository {scope}.")
|
||||||
|
|
||||||
|
ActionsWorkflowEnable = define("actions:workflow:enable", "Enabled Actions workflow {workflow} in repository {scope}.")
|
||||||
|
ActionsWorkflowDisable = define("actions:workflow:disable", "Disabled Actions workflow {workflow} in repository {scope}.")
|
||||||
|
ActionsWorkflowDispatch = define("actions:workflow:dispatch", "Dispatched Actions workflow {workflow} on {ref} in repository {scope}.")
|
||||||
|
|
||||||
|
// Do not change the startup message anymore. We guarantee the stability of this message for
|
||||||
|
// users wanting to parse the log themselves to be able to trace back events across gitea versions.
|
||||||
|
SystemStartup = define("system:startup", "System started [Gitea {version}]")
|
||||||
|
SystemShutdown = define("system:shutdown", "System shutdown")
|
||||||
|
SystemWebhookAdd = define("system:webhook:add", "Added instance-wide webhook {webhook}.")
|
||||||
|
SystemWebhookUpdate = define("system:webhook:update", "Updated instance-wide webhook {webhook}.")
|
||||||
|
SystemWebhookRemove = define("system:webhook:remove", "Removed instance-wide webhook {webhook}.")
|
||||||
|
SystemAuthenticationSourceAdd = define("system:authenticationsource:add", "Created authentication source {auth_source}.")
|
||||||
|
SystemAuthenticationSourceUpdate = define("system:authenticationsource:update", "Updated authentication source {auth_source}.")
|
||||||
|
SystemAuthenticationSourceRemove = define("system:authenticationsource:remove", "Removed authentication source {auth_source}.")
|
||||||
|
SystemOAuth2ApplicationAdd = define("system:oauth2application:add", "Added instance-wide OAuth2 application {oauth2_application}.")
|
||||||
|
SystemOAuth2ApplicationUpdate = define("system:oauth2application:update", "Updated instance-wide OAuth2 application {oauth2_application}.")
|
||||||
|
SystemOAuth2ApplicationSecret = define("system:oauth2application:secret", "Regenerated secret for instance-wide OAuth2 application {oauth2_application}.")
|
||||||
|
SystemOAuth2ApplicationRemove = define("system:oauth2application:remove", "Removed instance-wide OAuth2 application {oauth2_application}.")
|
||||||
|
)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
"gitea.dev/modules/json"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/builder"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(Event))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
Action Action `xorm:"INDEX NOT NULL"`
|
||||||
|
ActorID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
ActorName string
|
||||||
|
ActorCredential string // Credential the actor acted with, e.g. "access-token:<id>", "oauth2-grant:<id>", "gitea-actions:<task id>" or "deploy-key:<key id>".
|
||||||
|
ImpersonatorID int64 `xorm:"INDEX"` // Admin acting as the actor; zero when the actor acted themselves.
|
||||||
|
ImpersonatorName string
|
||||||
|
ScopeID int64 `xorm:"INDEX(scope) NOT NULL"` // Entity ID within ScopeType; zero for system.
|
||||||
|
ScopeType ScopeType `xorm:"INDEX INDEX(scope) NOT NULL"`
|
||||||
|
ScopeName string
|
||||||
|
Origin Origin `xorm:"INDEX NOT NULL"`
|
||||||
|
Message string
|
||||||
|
Metadata string `xorm:"LONGTEXT JSON"`
|
||||||
|
IPAddress string
|
||||||
|
TimestampUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Event) TableName() string {
|
||||||
|
return "audit_event"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) Actor() EntityRef {
|
||||||
|
return EntityRef{Type: ScopeUser, ID: e.ActorID, Name: e.ActorName}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impersonator returns the admin who acted as the actor, or nil.
|
||||||
|
func (e *Event) Impersonator() *EntityRef {
|
||||||
|
if e.ImpersonatorID == 0 && e.ImpersonatorName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &EntityRef{Type: ScopeUser, ID: e.ImpersonatorID, Name: e.ImpersonatorName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) Scope() EntityRef {
|
||||||
|
return EntityRef{Type: e.ScopeType, ID: e.ScopeID, Name: e.ScopeName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) Time() time.Time {
|
||||||
|
return e.TimestampUnix.AsTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventJSON is the nested JSONL export shape.
|
||||||
|
type eventJSON struct {
|
||||||
|
Action Action `json:"action"`
|
||||||
|
Actor EntityRef `json:"actor"`
|
||||||
|
ActorCredential string `json:"actor_credential,omitempty"`
|
||||||
|
Impersonator *EntityRef `json:"impersonator,omitempty"`
|
||||||
|
Scope EntityRef `json:"scope"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Origin Origin `json:"origin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(eventJSON{
|
||||||
|
Action: e.Action,
|
||||||
|
Actor: e.Actor(),
|
||||||
|
ActorCredential: e.ActorCredential,
|
||||||
|
Impersonator: e.Impersonator(),
|
||||||
|
Scope: e.Scope(),
|
||||||
|
Message: e.Message,
|
||||||
|
Metadata: DecodeMetadata(e.Metadata),
|
||||||
|
Time: e.Time(),
|
||||||
|
IPAddress: e.IPAddress,
|
||||||
|
Origin: e.Origin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Event) UnmarshalJSON(data []byte) error {
|
||||||
|
var j eventJSON
|
||||||
|
if err := json.Unmarshal(data, &j); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.Action = j.Action
|
||||||
|
e.ActorID = j.Actor.ID
|
||||||
|
e.ActorName = j.Actor.Name
|
||||||
|
e.ActorCredential = j.ActorCredential
|
||||||
|
if j.Impersonator != nil {
|
||||||
|
e.ImpersonatorID = j.Impersonator.ID
|
||||||
|
e.ImpersonatorName = j.Impersonator.Name
|
||||||
|
}
|
||||||
|
e.ScopeType = j.Scope.Type
|
||||||
|
e.ScopeID = j.Scope.ID
|
||||||
|
e.ScopeName = j.Scope.Name
|
||||||
|
e.Message = j.Message
|
||||||
|
e.Metadata = EncodeMetadata(j.Metadata)
|
||||||
|
e.IPAddress = j.IPAddress
|
||||||
|
e.Origin = j.Origin
|
||||||
|
e.TimestampUnix = timeutil.TimeStamp(j.Time.Unix())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeMetadata(m map[string]any) string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to encode audit metadata: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeMetadata(raw string) map[string]any {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
|
log.Error("Failed to decode audit metadata: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func InsertEvent(ctx context.Context, e *Event) error {
|
||||||
|
return db.Insert(ctx, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOldEvents removes events older than the given duration, keeping everything if it is not positive.
|
||||||
|
func DeleteOldEvents(ctx context.Context, olderThan time.Duration) error {
|
||||||
|
if olderThan <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := db.GetEngine(ctx).Where("timestamp_unix < ?", time.Now().Add(-olderThan).Unix()).Delete(&Event{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventSort string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SortTimestampAsc EventSort = "timestamp_asc"
|
||||||
|
SortTimestampDesc EventSort = "timestamp_desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventSearchOptions struct {
|
||||||
|
db.ListOptions
|
||||||
|
Action Action
|
||||||
|
// ActionPrefix filters an action family. It is mutually exclusive with Action.
|
||||||
|
ActionPrefix Action
|
||||||
|
ActorID int64
|
||||||
|
ScopeType ScopeType
|
||||||
|
ScopeID int64
|
||||||
|
Origin Origin
|
||||||
|
Sort EventSort
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opts *EventSearchOptions) ToConds() builder.Cond {
|
||||||
|
cond := builder.NewCond()
|
||||||
|
|
||||||
|
if opts.Action != "" {
|
||||||
|
cond = cond.And(builder.Eq{"action": opts.Action})
|
||||||
|
} else if opts.ActionPrefix != "" {
|
||||||
|
cond = cond.And(builder.Like{"action", string(opts.ActionPrefix) + ":%"})
|
||||||
|
}
|
||||||
|
if opts.ActorID != 0 {
|
||||||
|
// an impersonated event belongs to both the actor and the admin behind it
|
||||||
|
cond = cond.And(builder.Eq{"actor_id": opts.ActorID}.Or(builder.Eq{"impersonator_id": opts.ActorID}))
|
||||||
|
}
|
||||||
|
// applied independently so a missing scope ID narrows the query instead of
|
||||||
|
// silently widening it to every scope
|
||||||
|
if opts.ScopeType != "" {
|
||||||
|
cond = cond.And(builder.Eq{"scope_type": opts.ScopeType})
|
||||||
|
}
|
||||||
|
if opts.ScopeID != 0 {
|
||||||
|
cond = cond.And(builder.Eq{"scope_id": opts.ScopeID})
|
||||||
|
}
|
||||||
|
if opts.Origin != "" {
|
||||||
|
cond = cond.And(builder.Eq{"origin": opts.Origin})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cond
|
||||||
|
}
|
||||||
|
|
||||||
|
func (opts *EventSearchOptions) ToOrders() string {
|
||||||
|
if opts.Sort == SortTimestampAsc {
|
||||||
|
return "timestamp_unix ASC, id ASC"
|
||||||
|
}
|
||||||
|
return "timestamp_unix DESC, id DESC"
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindEvents(ctx context.Context, opts *EventSearchOptions) ([]*Event, int64, error) {
|
||||||
|
return db.FindAndCount[Event](ctx, opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkInsertEvent measures the synchronous database cost added when audit
|
||||||
|
// recording is enabled. Keep it separate from router benchmarks so it remains
|
||||||
|
// comparable across changes to request handling.
|
||||||
|
func BenchmarkInsertEvent(b *testing.B) {
|
||||||
|
if err := unittest.PrepareTestDatabase(); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
event := &Event{
|
||||||
|
Action: UserPassword,
|
||||||
|
ActorID: 1,
|
||||||
|
ActorName: "actor",
|
||||||
|
ScopeType: ScopeUser,
|
||||||
|
ScopeID: 2,
|
||||||
|
ScopeName: "scope",
|
||||||
|
Origin: OriginUI,
|
||||||
|
Metadata: `{"source":"benchmark"}`,
|
||||||
|
TimestampUnix: timeutil.TimeStamp(i + 1),
|
||||||
|
}
|
||||||
|
if err := InsertEvent(ctx, event); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindEventsScopeFilters(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
events := []*Event{
|
||||||
|
{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 5, Origin: OriginUI, TimestampUnix: timeutil.TimeStamp(1)},
|
||||||
|
{Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 5, Origin: OriginAPI, TimestampUnix: timeutil.TimeStamp(1)},
|
||||||
|
{Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 6, Origin: OriginCLI, TimestampUnix: timeutil.TimeStamp(1)},
|
||||||
|
{Action: RepositoryCreate, ScopeType: ScopeRepository, ScopeID: 7, Origin: OriginSystem, TimestampUnix: timeutil.TimeStamp(1)},
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
require.NoError(t, InsertEvent(t.Context(), event))
|
||||||
|
}
|
||||||
|
|
||||||
|
byType, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeType: ScopeRepository})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, byType, 3)
|
||||||
|
|
||||||
|
byID, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeID: 5})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, byID, 2)
|
||||||
|
|
||||||
|
byScope, _, err := FindEvents(t.Context(), &EventSearchOptions{ScopeType: ScopeRepository, ScopeID: 5})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, byScope, 1)
|
||||||
|
|
||||||
|
byOrigin, _, err := FindEvents(t.Context(), &EventSearchOptions{Origin: OriginAPI})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, byOrigin, 1)
|
||||||
|
|
||||||
|
bySystemOrigin, _, err := FindEvents(t.Context(), &EventSearchOptions{Origin: OriginSystem})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, bySystemOrigin, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindEventsActionPrefixFilter(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
for _, action := range []Action{UserImpersonation, UserImpersonationExit, UserCreate} {
|
||||||
|
require.NoError(t, InsertEvent(t.Context(), &Event{Action: action, ScopeType: ScopeUser, ScopeID: 1, TimestampUnix: timeutil.TimeStamp(1)}))
|
||||||
|
}
|
||||||
|
|
||||||
|
events, _, err := FindEvents(t.Context(), &EventSearchOptions{ActionPrefix: "user:impersonation"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, events, 2)
|
||||||
|
|
||||||
|
exact, _, err := FindEvents(t.Context(), &EventSearchOptions{Action: UserImpersonationExit})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, exact, 1)
|
||||||
|
assert.Equal(t, UserImpersonationExit, exact[0].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering for an admin must surface what they did while impersonating someone.
|
||||||
|
func TestFindEventsActorFilterIncludesImpersonations(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
events := []*Event{
|
||||||
|
{Action: UserPassword, ActorID: 10, ScopeType: ScopeUser, ScopeID: 10, TimestampUnix: timeutil.TimeStamp(1)},
|
||||||
|
{Action: UserPassword, ActorID: 11, ImpersonatorID: 10, ScopeType: ScopeUser, ScopeID: 11, TimestampUnix: timeutil.TimeStamp(2)},
|
||||||
|
{Action: UserPassword, ActorID: 12, ScopeType: ScopeUser, ScopeID: 12, TimestampUnix: timeutil.TimeStamp(3)},
|
||||||
|
}
|
||||||
|
for _, event := range events {
|
||||||
|
require.NoError(t, InsertEvent(t.Context(), event))
|
||||||
|
}
|
||||||
|
|
||||||
|
byAdmin, _, err := FindEvents(t.Context(), &EventSearchOptions{ActorID: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, byAdmin, 2)
|
||||||
|
|
||||||
|
byImpersonated, _, err := FindEvents(t.Context(), &EventSearchOptions{ActorID: 11})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, byImpersonated, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteOldEvents(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
old := &Event{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 1, TimestampUnix: timeutil.TimeStamp(now.Add(-48 * time.Hour).Unix())}
|
||||||
|
recent := &Event{Action: UserCreate, ScopeType: ScopeUser, ScopeID: 2, TimestampUnix: timeutil.TimeStamp(now.Unix())}
|
||||||
|
require.NoError(t, InsertEvent(t.Context(), old))
|
||||||
|
require.NoError(t, InsertEvent(t.Context(), recent))
|
||||||
|
|
||||||
|
require.NoError(t, DeleteOldEvents(t.Context(), 0)) // keeps everything
|
||||||
|
_, count, err := FindEvents(t.Context(), &EventSearchOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 2, count)
|
||||||
|
|
||||||
|
require.NoError(t, DeleteOldEvents(t.Context(), 24*time.Hour))
|
||||||
|
remaining, _, err := FindEvents(t.Context(), &EventSearchOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, remaining, 1)
|
||||||
|
assert.Equal(t, recent.ID, remaining[0].ID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntityRef is a denormalized reference persisted at record time.
|
||||||
|
type EntityRef struct {
|
||||||
|
Type ScopeType `json:"type"`
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r EntityRef) DisplayName() string {
|
||||||
|
if r.Name != "" {
|
||||||
|
return r.Name
|
||||||
|
}
|
||||||
|
if r.Type == ScopeSystem {
|
||||||
|
return "System"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r EntityRef) HomeLink() string {
|
||||||
|
switch r.Type {
|
||||||
|
case ScopeUser, ScopeOrganization:
|
||||||
|
if r.Name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return setting.AppSubURL + "/" + url.PathEscape(r.Name)
|
||||||
|
case ScopeRepository:
|
||||||
|
if r.Name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return setting.AppSubURL + "/" + util.PathEscapeSegments(r.Name)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r EntityRef) HasLink() bool {
|
||||||
|
return r.HomeLink() != "" && r.ID > 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
unittest.MainTest(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
// Origin identifies how an audit event was initiated.
|
||||||
|
type Origin string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OriginUI Origin = "ui"
|
||||||
|
OriginAPI Origin = "api"
|
||||||
|
OriginCLI Origin = "cli"
|
||||||
|
OriginSystem Origin = "system"
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
// ScopeType identifies the unit an audit event belongs to (for filtering in UI).
|
||||||
|
// Target-specific details live in Metadata, not as typed objects in the audit package.
|
||||||
|
type ScopeType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeSystem ScopeType = "system"
|
||||||
|
ScopeUser ScopeType = "user"
|
||||||
|
ScopeOrganization ScopeType = "organization"
|
||||||
|
ScopeRepository ScopeType = "repository"
|
||||||
|
)
|
||||||
@@ -158,6 +158,17 @@ func UpdateAccessToken(ctx context.Context, t *AccessToken) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAccessTokenByID returns the access token with the given ID owned by userID.
|
||||||
|
func GetAccessTokenByID(ctx context.Context, id, userID int64) (*AccessToken, error) {
|
||||||
|
t, has, err := db.Get[AccessToken](ctx, builder.Eq{"id": id, "uid": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !has {
|
||||||
|
return nil, util.NewNotExistErrorf("access token not found")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteAccessTokenByID deletes access token by given ID.
|
// DeleteAccessTokenByID deletes access token by given ID.
|
||||||
func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
|
func DeleteAccessTokenByID(ctx context.Context, id, userID int64) error {
|
||||||
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID})
|
cnt, err := db.GetEngine(ctx).ID(id).Delete(&AccessToken{UID: userID})
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ func AddUserOpenID(ctx context.Context, openid *UserOpenID) error {
|
|||||||
return db.Insert(ctx, openid)
|
return db.Insert(ctx, openid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserOpenIDByID returns the OpenID with the given ID owned by uid.
|
||||||
|
func GetUserOpenIDByID(ctx context.Context, id, uid int64) (*UserOpenID, error) {
|
||||||
|
oid, has, err := db.Get[UserOpenID](ctx, builder.Eq{"id": id, "uid": uid})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !has {
|
||||||
|
return nil, util.NewNotExistErrorf("OpenID is unknown")
|
||||||
|
}
|
||||||
|
return oid, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteUserOpenID deletes an openid address of given user.
|
// DeleteUserOpenID deletes an openid address of given user.
|
||||||
func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) {
|
func DeleteUserOpenID(ctx context.Context, openid *UserOpenID) (err error) {
|
||||||
var deleted int64
|
var deleted int64
|
||||||
|
|||||||
@@ -90,6 +90,32 @@ func NewDeployKeyUserWithKeyID(id int64) *User {
|
|||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
CLIUserID int64 = -4
|
||||||
|
CLIUserName = "CLI"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCLIUser() *User {
|
||||||
|
return &User{
|
||||||
|
ID: CLIUserID,
|
||||||
|
Name: CLIUserName,
|
||||||
|
LowerName: strings.ToLower(CLIUserName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthenticationSourceUserID int64 = -5
|
||||||
|
AuthenticationSourceUserName = "AuthenticationSource"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAuthenticationSourceUser() *User {
|
||||||
|
return &User{
|
||||||
|
ID: AuthenticationSourceUserID,
|
||||||
|
Name: AuthenticationSourceUserName,
|
||||||
|
LowerName: strings.ToLower(AuthenticationSourceUserName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetSystemUserByName(name string) *User {
|
func GetSystemUserByName(name string) *User {
|
||||||
lowerName := strings.ToLower(name)
|
lowerName := strings.ToLower(name)
|
||||||
uid := globalVars().systemUserNameIdMap[lowerName]
|
uid := globalVars().systemUserNameIdMap[lowerName]
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ func MarkRequestSupportPublicURL(ctx reqctx.RequestContext) {
|
|||||||
ctx.SetContextValue(contextKeySupportPublicURL, true)
|
ctx.SetContextValue(contextKeySupportPublicURL, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoteHost returns the host part of req.RemoteAddr, or the full address when
|
||||||
|
// it is not host:port form.
|
||||||
|
func RemoteHost(req *http.Request) string {
|
||||||
|
if req == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return req.RemoteAddr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
func urlIsRelative(s string, u *url.URL) bool {
|
func urlIsRelative(s string, u *url.URL) bool {
|
||||||
// Unfortunately, browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
|
// Unfortunately, browsers consider a redirect Location with preceding "//", "\\", "/\" and "\/" as meaning redirect to "http(s)://REST_OF_PATH"
|
||||||
// Therefore we should ignore these redirect locations to prevent open redirects
|
// Therefore we should ignore these redirect locations to prevent open redirects
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditRecordOutput string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuditRecordOutputDisabled AuditRecordOutput = "disabled"
|
||||||
|
AuditRecordOutputDatabase AuditRecordOutput = "database"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Audit = struct {
|
||||||
|
RecordOutput AuditRecordOutput `ini:"RECORD_OUTPUT"`
|
||||||
|
RetentionDays int64 `ini:"RETENTION_DAYS"`
|
||||||
|
}{
|
||||||
|
RecordOutput: AuditRecordOutputDisabled,
|
||||||
|
RetentionDays: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAuditFrom(rootCfg ConfigProvider) {
|
||||||
|
mustMapSetting(rootCfg, "audit", &Audit)
|
||||||
|
|
||||||
|
Audit.RecordOutput = AuditRecordOutput(strings.ToLower(strings.TrimSpace(string(Audit.RecordOutput))))
|
||||||
|
switch Audit.RecordOutput {
|
||||||
|
case "":
|
||||||
|
Audit.RecordOutput = AuditRecordOutputDisabled
|
||||||
|
case AuditRecordOutputDisabled, AuditRecordOutputDatabase:
|
||||||
|
default:
|
||||||
|
log.Error("Invalid [audit].RECORD_OUTPUT %q, audit records are disabled", Audit.RecordOutput)
|
||||||
|
Audit.RecordOutput = AuditRecordOutputDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if Audit.RetentionDays < 0 {
|
||||||
|
Audit.RetentionDays = 0 // keep forever
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRetentionPeriod is the age at which recorded events are pruned, zero meaning they are kept forever.
|
||||||
|
func AuditRetentionPeriod() time.Duration {
|
||||||
|
return time.Duration(Audit.RetentionDays) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRecordEnabled reports whether audit events are recorded at all.
|
||||||
|
func AuditRecordEnabled() bool {
|
||||||
|
return Audit.RecordOutput != AuditRecordOutputDisabled
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadAuditFrom(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cfg string
|
||||||
|
expected AuditRecordOutput
|
||||||
|
}{
|
||||||
|
{name: "DisabledByDefault", cfg: "", expected: AuditRecordOutputDisabled},
|
||||||
|
{name: "Database", cfg: "[audit]\nRECORD_OUTPUT = Database\n", expected: AuditRecordOutputDatabase},
|
||||||
|
{name: "Empty", cfg: "[audit]\nRECORD_OUTPUT =\n", expected: AuditRecordOutputDisabled},
|
||||||
|
{name: "Invalid", cfg: "[audit]\nRECORD_OUTPUT = nonsense\n", expected: AuditRecordOutputDisabled},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
defer test.MockVariableValue(&Audit)()
|
||||||
|
|
||||||
|
cfg, err := NewConfigProviderFromData(tc.cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
loadAuditFrom(cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expected, Audit.RecordOutput)
|
||||||
|
assert.Equal(t, tc.expected != AuditRecordOutputDisabled, AuditRecordEnabled())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
|||||||
// WARNING: don't change the sequence except you know what you are doing.
|
// WARNING: don't change the sequence except you know what you are doing.
|
||||||
loadRunModeFrom(cfg)
|
loadRunModeFrom(cfg)
|
||||||
loadLogGlobalFrom(cfg)
|
loadLogGlobalFrom(cfg)
|
||||||
|
loadAuditFrom(cfg)
|
||||||
loadServerFrom(cfg)
|
loadServerFrom(cfg)
|
||||||
loadSSHFrom(cfg)
|
loadSSHFrom(cfg)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ContextDataKeySignedUser = "SignedUser"
|
const (
|
||||||
|
ContextDataKeySignedUser = "SignedUser"
|
||||||
|
// ContextDataKeyImpersonator holds the admin acting as the signed-in user, if any.
|
||||||
|
ContextDataKeyImpersonator = "Impersonator"
|
||||||
|
// ContextDataKeyAuthCredential names the credential the request authenticated with, e.g. "access-token:42".
|
||||||
|
ContextDataKeyAuthCredential = "AuthCredential"
|
||||||
|
)
|
||||||
|
|
||||||
func GetContextData(c context.Context) reqctx.ContextData {
|
func GetContextData(c context.Context) reqctx.ContextData {
|
||||||
if rc := reqctx.GetRequestDataStore(c); rc != nil {
|
if rc := reqctx.GetRequestDataStore(c); rc != nil {
|
||||||
|
|||||||
@@ -3051,6 +3051,8 @@
|
|||||||
"admin.dashboard.gc_times": "GC Times",
|
"admin.dashboard.gc_times": "GC Times",
|
||||||
"admin.dashboard.delete_old_actions": "Delete all old activities from database",
|
"admin.dashboard.delete_old_actions": "Delete all old activities from database",
|
||||||
"admin.dashboard.delete_old_actions.started": "Deletion of all old activities from database started",
|
"admin.dashboard.delete_old_actions.started": "Deletion of all old activities from database started",
|
||||||
|
"admin.dashboard.delete_old_audit_events": "Delete audit events older than the retention period",
|
||||||
|
"admin.dashboard.delete_old_audit_events.started": "Deletion of old audit events started",
|
||||||
"admin.dashboard.update_checker": "Update checker",
|
"admin.dashboard.update_checker": "Update checker",
|
||||||
"admin.dashboard.delete_old_system_notices": "Delete all old system notices from database",
|
"admin.dashboard.delete_old_system_notices": "Delete all old system notices from database",
|
||||||
"admin.dashboard.gc_lfs": "Garbage-collect LFS meta objects",
|
"admin.dashboard.gc_lfs": "Garbage-collect LFS meta objects",
|
||||||
@@ -3764,6 +3766,24 @@
|
|||||||
"secrets.deletion.success": "The secret has been removed.",
|
"secrets.deletion.success": "The secret has been removed.",
|
||||||
"secrets.deletion.failed": "Failed to remove secret.",
|
"secrets.deletion.failed": "Failed to remove secret.",
|
||||||
"secrets.management": "Secrets Management",
|
"secrets.management": "Secrets Management",
|
||||||
|
|
||||||
|
"audit.title": "Audit Log",
|
||||||
|
"audit.export": "Export JSONL",
|
||||||
|
"audit.actor": "Actor",
|
||||||
|
"audit.scope": "Scope",
|
||||||
|
"audit.action": "Action",
|
||||||
|
"audit.origin": "Origin",
|
||||||
|
"audit.details": "Details",
|
||||||
|
"audit.ip_address": "IP Address",
|
||||||
|
"audit.timestamp": "Timestamp",
|
||||||
|
"audit.no_events": "There are no audit events matching the filter.",
|
||||||
|
"audit.disabled": "Audit event recording is disabled. Set [audit] RECORD_OUTPUT = database in app.ini to record new events.",
|
||||||
|
"audit.impersonating_as": "as",
|
||||||
|
"audit.filter.actor": "Filter by actor…",
|
||||||
|
"audit.filter.action": "All actions",
|
||||||
|
"audit.filter.origin": "All origins",
|
||||||
|
"audit.deleted.actor": "(removed)",
|
||||||
|
"audit.deleted.type": "(removed %[1]s with ID %[2]v)",
|
||||||
"actions.actions": "Actions",
|
"actions.actions": "Actions",
|
||||||
"actions.unit.desc": "Manage actions",
|
"actions.unit.desc": "Manage actions",
|
||||||
"actions.status.unknown": "Unknown",
|
"actions.status.unknown": "Unknown",
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
)
|
)
|
||||||
@@ -74,6 +76,8 @@ func CreateOrg(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.OrganizationCreate, org.AsUser())
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org))
|
ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
asymkey_model "gitea.dev/models/asymkey"
|
asymkey_model "gitea.dev/models/asymkey"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
org_model "gitea.dev/models/organization"
|
org_model "gitea.dev/models/organization"
|
||||||
@@ -26,6 +27,7 @@ import (
|
|||||||
"gitea.dev/routers/api/v1/user"
|
"gitea.dev/routers/api/v1/user"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
asymkey_service "gitea.dev/services/asymkey"
|
asymkey_service "gitea.dev/services/asymkey"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
"gitea.dev/services/mailer"
|
"gitea.dev/services/mailer"
|
||||||
@@ -152,6 +154,8 @@ func CreateUser(ctx *context.APIContext) {
|
|||||||
ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", u.Email))
|
ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", u.Email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserCreate, u)
|
||||||
|
|
||||||
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
|
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
|
||||||
|
|
||||||
// Send email notification.
|
// Send email notification.
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -95,6 +96,7 @@ import (
|
|||||||
"gitea.dev/routers/api/v1/user"
|
"gitea.dev/routers/api/v1/user"
|
||||||
"gitea.dev/routers/common"
|
"gitea.dev/routers/common"
|
||||||
"gitea.dev/services/actions"
|
"gitea.dev/services/actions"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/auth"
|
"gitea.dev/services/auth"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -124,7 +126,13 @@ func sudo() func(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Trace("Sudo from (%s) to: %s", ctx.Doer.Name, user.Name)
|
log.Trace("Sudo from (%s) to: %s", ctx.Doer.Name, user.Name)
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserImpersonation, user)
|
||||||
|
|
||||||
ctx.Doer = user
|
ctx.Doer = user
|
||||||
|
// keep the audit actor in step with the effective doer, and keep the admin attached to it
|
||||||
|
ctx.Data[middleware.ContextDataKeyImpersonator] = ctx.Data[middleware.ContextDataKeySignedUser]
|
||||||
|
ctx.Data[middleware.ContextDataKeySignedUser] = user
|
||||||
} else {
|
} else {
|
||||||
ctx.JSON(http.StatusForbidden, map[string]string{
|
ctx.JSON(http.StatusForbidden, map[string]string{
|
||||||
"message": "Only administrators allowed to sudo.",
|
"message": "Only administrators allowed to sudo.",
|
||||||
@@ -1021,6 +1029,7 @@ func Routes() *web.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.AfterRouting(context.APIContexter())
|
m.AfterRouting(context.APIContexter())
|
||||||
|
m.AfterRouting(common.AuditOrigin(audit_model.OriginAPI))
|
||||||
m.AfterRouting(checkDeprecatedAuthMethods)
|
m.AfterRouting(checkDeprecatedAuthMethods)
|
||||||
|
|
||||||
// Get user from session if logged in.
|
// Get user from session if logged in.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"gitea.dev/routers/api/v1/shared"
|
"gitea.dev/routers/api/v1/shared"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
actions_service "gitea.dev/services/actions"
|
actions_service "gitea.dev/services/actions"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
secret_service "gitea.dev/services/secrets"
|
secret_service "gitea.dev/services/secrets"
|
||||||
)
|
)
|
||||||
@@ -108,12 +109,18 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
|
|||||||
|
|
||||||
opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx)
|
opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx)
|
||||||
|
|
||||||
_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
s, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actions := audit.SecretUpdate
|
||||||
|
if created {
|
||||||
|
actions = audit.SecretAdd
|
||||||
|
}
|
||||||
|
audit.RecordScoped(ctx, ctx.Org.Organization.AsUser(), nil, actions, "secret", s.Name)
|
||||||
|
|
||||||
if created {
|
if created {
|
||||||
ctx.Status(http.StatusCreated)
|
ctx.Status(http.StatusCreated)
|
||||||
} else {
|
} else {
|
||||||
@@ -149,12 +156,14 @@ func (Action) DeleteSecret(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"))
|
s, err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.PathParam("secretname"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.RecordScoped(ctx, ctx.Org.Organization.AsUser(), nil, audit.SecretRemove, "secret", s.Name)
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
activities_model "gitea.dev/models/activities"
|
activities_model "gitea.dev/models/activities"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/api/v1/user"
|
"gitea.dev/routers/api/v1/user"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
feed_service "gitea.dev/services/feed"
|
feed_service "gitea.dev/services/feed"
|
||||||
@@ -296,6 +298,8 @@ func Create(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.OrganizationCreate, org.AsUser())
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org))
|
ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"gitea.dev/routers/api/v1/shared"
|
"gitea.dev/routers/api/v1/shared"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
actions_service "gitea.dev/services/actions"
|
actions_service "gitea.dev/services/actions"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
secret_service "gitea.dev/services/secrets"
|
secret_service "gitea.dev/services/secrets"
|
||||||
@@ -137,12 +138,18 @@ func (Action) CreateOrUpdateSecret(ctx *context.APIContext) {
|
|||||||
|
|
||||||
opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx)
|
opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx)
|
||||||
|
|
||||||
_, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
s, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actions := audit.SecretUpdate
|
||||||
|
if created {
|
||||||
|
actions = audit.SecretAdd
|
||||||
|
}
|
||||||
|
audit.RecordScoped(ctx, nil, repo, actions, "secret", s.Name)
|
||||||
|
|
||||||
if created {
|
if created {
|
||||||
ctx.Status(http.StatusCreated)
|
ctx.Status(http.StatusCreated)
|
||||||
} else {
|
} else {
|
||||||
@@ -185,12 +192,14 @@ func (Action) DeleteSecret(ctx *context.APIContext) {
|
|||||||
|
|
||||||
repo := ctx.Repo.Repository
|
repo := ctx.Repo.Repository
|
||||||
|
|
||||||
err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.PathParam("secretname"))
|
s, err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.PathParam("secretname"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.RecordScoped(ctx, nil, repo, audit.SecretRemove, "secret", s.Name)
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
activities_model "gitea.dev/models/activities"
|
activities_model "gitea.dev/models/activities"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -33,6 +34,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
actions_service "gitea.dev/services/actions"
|
actions_service "gitea.dev/services/actions"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
feed_service "gitea.dev/services/feed"
|
feed_service "gitea.dev/services/feed"
|
||||||
@@ -729,6 +731,10 @@ func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if visibilityChanged {
|
||||||
|
audit.Record(ctx, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate)
|
||||||
|
}
|
||||||
|
|
||||||
if updateRepoLicense {
|
if updateRepoLicense {
|
||||||
if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{
|
if err := repo_service.AddRepoToLicenseUpdaterQueue(&repo_service.LicenseUpdaterOptions{
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.dev/routers/api/v1/shared"
|
"gitea.dev/routers/api/v1/shared"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
actions_service "gitea.dev/services/actions"
|
actions_service "gitea.dev/services/actions"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
secret_service "gitea.dev/services/secrets"
|
secret_service "gitea.dev/services/secrets"
|
||||||
)
|
)
|
||||||
@@ -50,12 +51,18 @@ func CreateOrUpdateSecret(ctx *context.APIContext) {
|
|||||||
|
|
||||||
opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx)
|
opt := web.GetForm[*api.CreateOrUpdateSecretOption](ctx)
|
||||||
|
|
||||||
_, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
s, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"), opt.Data, opt.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actions := audit.SecretUpdate
|
||||||
|
if created {
|
||||||
|
actions = audit.SecretAdd
|
||||||
|
}
|
||||||
|
audit.RecordScoped(ctx, ctx.Doer, nil, actions, "secret", s.Name)
|
||||||
|
|
||||||
if created {
|
if created {
|
||||||
ctx.Status(http.StatusCreated)
|
ctx.Status(http.StatusCreated)
|
||||||
} else {
|
} else {
|
||||||
@@ -86,12 +93,14 @@ func DeleteSecret(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"))
|
s, err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.PathParam("secretname"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.RecordScoped(ctx, ctx.Doer, nil, audit.SecretRemove, "secret", s.Name)
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/api/v1/utils"
|
"gitea.dev/routers/api/v1/utils"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -149,6 +151,9 @@ func CreateAccessToken(ctx *context.APIContext) {
|
|||||||
ctx.APIErrorInternal(err)
|
ctx.APIErrorInternal(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.ContextUser, "token", t.Name, "token_scope", t.Scope)
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, &api.AccessToken{
|
ctx.JSON(http.StatusCreated, &api.AccessToken{
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Token: t.Token,
|
Token: t.Token,
|
||||||
@@ -211,11 +216,19 @@ func DeleteAccessToken(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil {
|
t, err := auth_model.GetAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID)
|
||||||
|
if err != nil {
|
||||||
ctx.APIErrorAuto(err)
|
ctx.APIErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.ContextUser.ID); err != nil {
|
||||||
|
ctx.APIErrorAuto(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.ContextUser, "token", t.Name)
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +274,8 @@ func CreateOauth2Application(ctx *context.APIContext) {
|
|||||||
}
|
}
|
||||||
app.ClientSecret = secret
|
app.ClientSecret = secret
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserOAuth2ApplicationAdd, ctx.Doer, "oauth2_application", app.Name)
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, convert.ToOAuth2Application(app))
|
ctx.JSON(http.StatusCreated, convert.ToOAuth2Application(app))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +338,15 @@ func DeleteOauth2Application(ctx *context.APIContext) {
|
|||||||
// "404":
|
// "404":
|
||||||
// "$ref": "#/responses/notFound"
|
// "$ref": "#/responses/notFound"
|
||||||
appID := ctx.PathParamInt64("id")
|
appID := ctx.PathParamInt64("id")
|
||||||
|
app, err := auth_model.GetOAuth2ApplicationByID(ctx, appID)
|
||||||
|
if err != nil {
|
||||||
|
if auth_model.IsErrOAuthApplicationNotFound(err) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
} else {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := auth_model.DeleteOAuth2Application(ctx, appID, ctx.Doer.ID); err != nil {
|
if err := auth_model.DeleteOAuth2Application(ctx, appID, ctx.Doer.ID); err != nil {
|
||||||
if auth_model.IsErrOAuthApplicationNotFound(err) {
|
if auth_model.IsErrOAuthApplicationNotFound(err) {
|
||||||
ctx.APIErrorNotFound()
|
ctx.APIErrorNotFound()
|
||||||
@@ -332,6 +356,8 @@ func DeleteOauth2Application(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserOAuth2ApplicationRemove, ctx.Doer, "oauth2_application", app.Name)
|
||||||
|
|
||||||
ctx.Status(http.StatusNoContent)
|
ctx.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,5 +456,7 @@ func UpdateOauth2Application(ctx *context.APIContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserOAuth2ApplicationUpdate, ctx.Doer, "oauth2_application", app.Name)
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, convert.ToOAuth2Application(app))
|
ctx.JSON(http.StatusOK, convert.ToOAuth2Application(app))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
"gitea.dev/modules/httplib"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
|
audit_service "gitea.dev/services/audit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditOrigin publishes the origin and the client address of the request, so
|
||||||
|
// audit events recorded while serving it are attributed to it.
|
||||||
|
func AuditOrigin(origin audit_model.Origin) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||||
|
if store := reqctx.GetRequestDataStore(req.Context()); store != nil {
|
||||||
|
audit_service.SetRequestInfo(store, origin, httplib.RemoteHost(req))
|
||||||
|
}
|
||||||
|
next.ServeHTTP(resp, req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/web/middleware"
|
"gitea.dev/modules/web/middleware"
|
||||||
auth_service "gitea.dev/services/auth"
|
auth_service "gitea.dev/services/auth"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
@@ -30,6 +31,18 @@ func AuthShared(ctx *context.Base, sessionStore auth_service.SessionStore, authM
|
|||||||
ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer
|
ctx.Data[middleware.ContextDataKeySignedUser] = ar.Doer
|
||||||
ctx.Data["SignedUserID"] = ar.Doer.ID
|
ctx.Data["SignedUserID"] = ar.Doer.ID
|
||||||
ctx.Data["IsAdmin"] = ar.Doer.IsAdmin
|
ctx.Data["IsAdmin"] = ar.Doer.IsAdmin
|
||||||
|
|
||||||
|
if sessionStore != nil {
|
||||||
|
if uid := auth_service.ImpersonatorUserID(sessionStore); uid != 0 {
|
||||||
|
impersonator, err := user_model.GetUserByID(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
// the session stays usable, but audit events must not silently lose the admin behind it
|
||||||
|
log.Error("Unable to resolve impersonator %d: %v", uid, err)
|
||||||
|
} else {
|
||||||
|
ctx.Data[middleware.ContextDataKeyImpersonator] = impersonator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.Data["SignedUserID"] = int64(0)
|
ctx.Data["SignedUserID"] = int64(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
db_install "gitea.dev/models/db/install"
|
db_install "gitea.dev/models/db/install"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
@@ -30,6 +31,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/modules/web/middleware"
|
"gitea.dev/modules/web/middleware"
|
||||||
"gitea.dev/routers/common"
|
"gitea.dev/routers/common"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
auth_service "gitea.dev/services/auth"
|
auth_service "gitea.dev/services/auth"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -508,6 +510,8 @@ func saveConfigAndRestart(ctx *context.Context, cfg setting.ConfigProvider, form
|
|||||||
u, _ = user_model.GetUserByName(ctx, u.Name)
|
u, _ = user_model.GetUserByName(ctx, u.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.RecordAs(ctx, u, audit_model.UserCreate, u)
|
||||||
|
|
||||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
ctx.ServerError("CreateAuthTokenForUserID", err)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
git_model "gitea.dev/models/git"
|
git_model "gitea.dev/models/git"
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
gitea_context "gitea.dev/services/context"
|
gitea_context "gitea.dev/services/context"
|
||||||
pull_service "gitea.dev/services/pull"
|
pull_service "gitea.dev/services/pull"
|
||||||
repo_service "gitea.dev/services/repository"
|
repo_service "gitea.dev/services/repository"
|
||||||
@@ -154,6 +156,8 @@ func hookPostReceiveUpdateRepoByOptions(ctx *gitea_context.PrivateContext, opts
|
|||||||
repo.IsPrivate = isPrivate.Value()
|
repo.IsPrivate = isPrivate.Value()
|
||||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||||
log.Error("failed to update repo is_private: %v", err)
|
log.Error("failed to update repo is_private: %v", err)
|
||||||
|
} else {
|
||||||
|
audit.RecordAs(ctx, ctx.Doer, audit_model.RepositoryVisibility, repo, "visibility", repo.IsPrivate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
|
if isTemplate.Has() && repo.IsTemplate != isTemplate.Value() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/private"
|
"gitea.dev/modules/private"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
@@ -74,6 +75,7 @@ func Routes() *web.Router {
|
|||||||
// Log the real ip address of the request from SSH is really helpful for diagnosing sometimes.
|
// Log the real ip address of the request from SSH is really helpful for diagnosing sometimes.
|
||||||
// Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
|
// Since internal API will be sent only from Gitea sub commands and it's under control (checked by InternalToken), we can trust the headers.
|
||||||
r.AfterRouting(setRealIP)
|
r.AfterRouting(setRealIP)
|
||||||
|
r.AfterRouting(common.AuditOrigin(audit_model.OriginSystem))
|
||||||
|
|
||||||
r.Get("/dummy", misc.DummyOK)
|
r.Get("/dummy", misc.DummyOK)
|
||||||
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
|
r.Post("/ssh/authorized_keys", AuthorizedPublicKeyByContent)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var (
|
|||||||
|
|
||||||
func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers {
|
func newOAuth2CommonHandlers() *user_setting.OAuth2CommonHandlers {
|
||||||
return &user_setting.OAuth2CommonHandlers{
|
return &user_setting.OAuth2CommonHandlers{
|
||||||
OwnerID: 0,
|
Owner: nil, // instance-wide
|
||||||
BasePathList: setting.AppSubURL + "/-/admin/applications",
|
BasePathList: setting.AppSubURL + "/-/admin/applications",
|
||||||
BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2",
|
BasePathEditPrefix: setting.AppSubURL + "/-/admin/applications/oauth2",
|
||||||
TplAppEdit: tplSettingsOauth2ApplicationEdit,
|
TplAppEdit: tplSettingsOauth2ApplicationEdit,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
"gitea.dev/modules/httplib"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
shared_audit "gitea.dev/routers/web/shared/audit"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
|
"gitea.dev/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const auditExportPageSize = 1000
|
||||||
|
|
||||||
|
func ViewAuditLogs(ctx *context.Context) {
|
||||||
|
shared_audit.View(ctx, shared_audit.ViewOptions{
|
||||||
|
Template: "admin/audit/list",
|
||||||
|
PageData: map[string]any{"PageIsAdminMonitorAudit": true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExportAuditLogs(ctx *context.Context) {
|
||||||
|
// the export mirrors the filters of the listing it was started from
|
||||||
|
searchOpts := shared_audit.SearchOptionsFromRequest(ctx, "", 0)
|
||||||
|
searchOpts.Sort = audit_model.SortTimestampAsc
|
||||||
|
searchOpts.PageSize = auditExportPageSize
|
||||||
|
|
||||||
|
page := 1
|
||||||
|
findPage := func() ([]*audit_model.Event, int64, error) {
|
||||||
|
searchOpts.Page = page
|
||||||
|
return audit.FindEvents(ctx, searchOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the first page is fetched before any header is written so a failing query still results in a proper error page
|
||||||
|
events, total, err := findPage()
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("FindEvents", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httplib.ServeSetHeaders(ctx.Resp, httplib.ServeHeaderOptions{
|
||||||
|
ContentType: "application/x-ndjson; charset=utf-8",
|
||||||
|
Filename: fmt.Sprintf("gitea-audit-log-%s.jsonl", time.Now().UTC().Format("20060102-150405Z")),
|
||||||
|
ContentDisposition: httplib.ContentDispositionAttachment,
|
||||||
|
})
|
||||||
|
ctx.SetTotalCountHeader(total) // lets a client detect an export truncated by a mid-stream failure
|
||||||
|
ctx.Resp.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := audit.WriteEventsAsJSON(ctx.Resp, events); err != nil {
|
||||||
|
log.Debug("Unable to write audit log export: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(events) < auditExportPageSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page++
|
||||||
|
events, _, err = findPage()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to continue audit log export: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -301,7 +301,7 @@ func NewAuthSourcePost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.CreateSource(ctx, &auth.Source{
|
if err := auth_service.CreateSource(ctx, &auth.Source{
|
||||||
Type: auth.Type(form.Type),
|
Type: auth.Type(form.Type),
|
||||||
Name: form.Name,
|
Name: form.Name,
|
||||||
IsActive: form.IsActive,
|
IsActive: form.IsActive,
|
||||||
@@ -419,7 +419,7 @@ func EditAuthSourcePost(ctx *context.Context) {
|
|||||||
source.IsSyncEnabled = form.IsSyncEnabled
|
source.IsSyncEnabled = form.IsSyncEnabled
|
||||||
source.Cfg = config
|
source.Cfg = config
|
||||||
source.TwoFactorPolicy = form.TwoFactorPolicy
|
source.TwoFactorPolicy = form.TwoFactorPolicy
|
||||||
if err := auth.UpdateSource(ctx, source); err != nil {
|
if err := auth_service.UpdateSource(ctx, source); err != nil {
|
||||||
if errExist, ok := errors.AsType[auth.ErrSourceAlreadyExist](err); ok {
|
if errExist, ok := errors.AsType[auth.ErrSourceAlreadyExist](err); ok {
|
||||||
ctx.Data["Err_Name"] = true
|
ctx.Data["Err_Name"] = true
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_exist", errExist.Name), tplAuthEdit, form)
|
ctx.RenderWithErrDeprecated(ctx.Tr("admin.auths.login_source_exist", errExist.Name), tplAuthEdit, form)
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/user"
|
"gitea.dev/services/user"
|
||||||
)
|
)
|
||||||
@@ -129,6 +131,13 @@ func ActivateEmail(ctx *context.Context) {
|
|||||||
ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err))
|
ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// the email already changed, so a failed lookup must not fail the request
|
||||||
|
if u, err := user_model.GetUserByID(ctx, uid); err != nil {
|
||||||
|
log.Error("GetUserByID(%d) for audit: %v", uid, err)
|
||||||
|
} else {
|
||||||
|
audit.Record(ctx, audit_model.UserEmailActivate, u, "email", email, "activated", activate)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate)
|
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"))
|
ctx.Flash.Info(ctx.Tr("admin.emails.updated"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,9 +62,14 @@ func DefaultOrSystemWebhooks(ctx *context.Context) {
|
|||||||
|
|
||||||
// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook
|
// DeleteDefaultOrSystemWebhook handler to delete an admin-defined system or default webhook
|
||||||
func DeleteDefaultOrSystemWebhook(ctx *context.Context) {
|
func DeleteDefaultOrSystemWebhook(ctx *context.Context) {
|
||||||
if err := webhook.DeleteDefaultSystemWebhook(ctx, ctx.FormInt64("id")); err != nil {
|
hook, err := webhook.GetWebhookByID(ctx, ctx.FormInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Flash.Error("GetWebhookByID: " + err.Error())
|
||||||
|
} else if err := webhook.DeleteDefaultSystemWebhook(ctx, hook.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error())
|
ctx.Flash.Error("DeleteDefaultWebhook: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.RecordScoped(ctx, nil, nil, audit.WebhookRemove, "webhook", hook.URL)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
org_model "gitea.dev/models/organization"
|
org_model "gitea.dev/models/organization"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/web/explore"
|
"gitea.dev/routers/web/explore"
|
||||||
user_setting "gitea.dev/routers/web/user/setting"
|
user_setting "gitea.dev/routers/web/user/setting"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
auth_service "gitea.dev/services/auth"
|
auth_service "gitea.dev/services/auth"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -203,6 +205,8 @@ func NewUserPost(ctx *context.Context) {
|
|||||||
ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email))
|
ctx.Flash.Warning(ctx.Tr("form.email_domain_is_not_allowed", u.Email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserCreate, u)
|
||||||
|
|
||||||
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
|
log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name)
|
||||||
|
|
||||||
// Send email notification.
|
// Send email notification.
|
||||||
@@ -468,6 +472,7 @@ func ImpersonateUser(ctx *context.Context) {
|
|||||||
ctx.ServerError("unable to impersonate user", err)
|
ctx.ServerError("unable to impersonate user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
audit.Record(ctx, audit_model.UserImpersonation, u)
|
||||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
|
ctx.JSONRedirect(setting.AppSubURL + "/user/settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -82,6 +84,10 @@ func TwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if u, err := user_model.GetUserByID(ctx, id); err == nil {
|
||||||
|
audit.RecordAs(ctx, u, audit_model.UserAuthenticationFailTwoFactor, u)
|
||||||
|
}
|
||||||
|
|
||||||
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
|
ctx.RenderWithErrDeprecated(ctx.Tr("auth.twofa_passcode_incorrect"), tplTwofa, forms.TwoFactorAuthForm{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/modules/web/middleware"
|
"gitea.dev/modules/web/middleware"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
auth_service "gitea.dev/services/auth"
|
auth_service "gitea.dev/services/auth"
|
||||||
"gitea.dev/services/auth/source/oauth2"
|
"gitea.dev/services/auth/source/oauth2"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
@@ -453,12 +455,15 @@ func SignOut(ctx *context.Context) {
|
|||||||
websocket_service.PublishLogout(ctx.Doer.ID, ctx.Session.ID())
|
websocket_service.PublishLogout(ctx.Doer.ID, ctx.Session.ID())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impersonator := audit.ImpersonatorFromContext(ctx)
|
||||||
|
|
||||||
exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session)
|
exitedImpersonated, err := auth_service.ExitImpersonatedUser(ctx.Session)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("ExitImpersonatedUser", err)
|
ctx.ServerError("ExitImpersonatedUser", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if exitedImpersonated {
|
if exitedImpersonated {
|
||||||
|
audit.RecordAs(ctx, impersonator, audit_model.UserImpersonationExit, ctx.Doer)
|
||||||
ctx.Redirect(setting.AppSubURL + "/-/admin")
|
ctx.Redirect(setting.AppSubURL + "/-/admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"gitea.dev/modules/proxy"
|
"gitea.dev/modules/proxy"
|
||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
source_service "gitea.dev/services/auth/source"
|
source_service "gitea.dev/services/auth/source"
|
||||||
"gitea.dev/services/auth/source/oauth2"
|
"gitea.dev/services/auth/source/oauth2"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
@@ -419,7 +420,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
// Register last login
|
// Register last login
|
||||||
opts.SetLastLogin = true
|
opts.SetLastLogin = true
|
||||||
|
|
||||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
if err := user_service.UpdateUser(audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser()), u, opts); err != nil {
|
||||||
ctx.ServerError("UpdateUser", err)
|
ctx.ServerError("UpdateUser", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -448,7 +449,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
if opts.IsActive.Has() || opts.IsAdmin.Has() || opts.IsRestricted.Has() {
|
||||||
if err := user_service.UpdateUser(ctx, u, opts); err != nil {
|
if err := user_service.UpdateUser(audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser()), u, opts); err != nil {
|
||||||
ctx.ServerError("UpdateUser", err)
|
ctx.ServerError("UpdateUser", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/httpauth"
|
"gitea.dev/modules/auth/httpauth"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
auth_service "gitea.dev/services/auth"
|
auth_service "gitea.dev/services/auth"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -120,7 +122,7 @@ func InfoOAuth(ctx *context.Context) {
|
|||||||
var accessTokenScope auth.AccessTokenScope
|
var accessTokenScope auth.AccessTokenScope
|
||||||
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
|
||||||
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
|
if parsed, ok := httpauth.ParseAuthorizationHeader(auHead); ok && parsed.BearerToken != nil {
|
||||||
accessTokenScope, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
|
accessTokenScope, _, _ = auth_service.GetOAuthAccessTokenScopeAndUserID(ctx, parsed.BearerToken.Token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +429,8 @@ func GrantApplicationOAuth(ctx *context.Context) {
|
|||||||
}, form.RedirectURI)
|
}, form.RedirectURI)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserOAuth2ApplicationGrant, ctx.Doer, "oauth2_application", app.Name, "granted_scope", form.Scope)
|
||||||
} else if grant.Scope != form.Scope {
|
} else if grant.Scope != form.Scope {
|
||||||
handleAuthorizeError(ctx, AuthorizeError{
|
handleAuthorizeError(ctx, AuthorizeError{
|
||||||
State: form.State,
|
State: form.State,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
asymkey_service "gitea.dev/services/asymkey"
|
asymkey_service "gitea.dev/services/asymkey"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/auth/source/oauth2"
|
"gitea.dev/services/auth/source/oauth2"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
user_service "gitea.dev/services/user"
|
user_service "gitea.dev/services/user"
|
||||||
@@ -54,7 +55,7 @@ func oauth2SignInSync(ctx *context.Context, authSourceID int64, u *user_model.Us
|
|||||||
// sync user flags (admin/restricted)
|
// sync user flags (admin/restricted)
|
||||||
isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
|
||||||
if isAdmin.Has() || isRestricted.Has() {
|
if isAdmin.Has() || isRestricted.Has() {
|
||||||
if err = user_service.UpdateUser(ctx, u, &user_service.UpdateOptions{IsAdmin: isAdmin, IsRestricted: isRestricted}); err != nil {
|
if err = user_service.UpdateUser(audit.WithDoer(ctx, user_model.NewAuthenticationSourceUser()), u, &user_service.UpdateOptions{IsAdmin: isAdmin, IsRestricted: isRestricted}); err != nil {
|
||||||
log.Error("Unable to sync OAuth2 user admin or restricted status %s: %v", gothUser.Provider, err)
|
log.Error("Unable to sync OAuth2 user admin or restricted status %s: %v", gothUser.Provider, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/password"
|
"gitea.dev/modules/auth/password"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
"gitea.dev/services/mailer"
|
"gitea.dev/services/mailer"
|
||||||
@@ -86,6 +88,10 @@ func ForgotPasswdPost(ctx *context.Context) {
|
|||||||
|
|
||||||
mailer.SendResetPasswordMail(u)
|
mailer.SendResetPasswordMail(u)
|
||||||
|
|
||||||
|
// the request is unauthenticated, so the affected account is the only actor
|
||||||
|
// we can name; the recorded IP address carries the forensic signal
|
||||||
|
audit.RecordAs(ctx, u, audit_model.UserPasswordResetRequest, u)
|
||||||
|
|
||||||
if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
|
if err = ctx.Cache.Put("MailResendLimit_"+u.LowerName, u.LowerName, 180); err != nil {
|
||||||
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
log.Error("Set cache(MailResendLimit) fail: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -79,6 +81,8 @@ func CreatePost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
audit.Record(ctx, audit_model.OrganizationCreate, org.AsUser())
|
||||||
|
|
||||||
log.Trace("Organization created: %s", org.Name)
|
log.Trace("Organization created: %s", org.Name)
|
||||||
|
|
||||||
ctx.Redirect(org.AsUser().DashboardLink())
|
ctx.Redirect(org.AsUser().DashboardLink())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/web/shared/issue"
|
"gitea.dev/routers/web/shared/issue"
|
||||||
shared_user "gitea.dev/routers/web/shared/user"
|
shared_user "gitea.dev/routers/web/shared/user"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
project_service "gitea.dev/services/projects"
|
project_service "gitea.dev/services/projects"
|
||||||
@@ -171,6 +173,7 @@ func NewProjectPost(ctx *context.Context) {
|
|||||||
ctx.ServerError("NewProject", err)
|
ctx.ServerError("NewProject", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
audit.Record(ctx, audit_model.ProjectCreate, ctx.ContextUser, "project", newProject.Title, "project_id", newProject.ID)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
|
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
|
||||||
ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects")
|
ctx.Redirect(ctx.ContextUser.HomeLink() + "/-/projects")
|
||||||
@@ -214,6 +217,7 @@ func DeleteProject(ctx *context.Context) {
|
|||||||
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
|
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
|
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.Record(ctx, audit_model.ProjectDelete, ctx.ContextUser, "project", p.Title, "project_id", p.ID)
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +288,7 @@ func EditProjectPost(ctx *context.Context) {
|
|||||||
ctx.ServerError("UpdateProjects", err)
|
ctx.ServerError("UpdateProjects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
audit.Record(ctx, audit_model.ProjectUpdate, ctx.ContextUser, "project", p.Title, "project_id", p.ID)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
|
ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
|
||||||
if ctx.FormString("redirect") == "project" {
|
if ctx.FormString("redirect") == "project" {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
shared_user "gitea.dev/routers/web/shared/user"
|
shared_user "gitea.dev/routers/web/shared/user"
|
||||||
user_setting "gitea.dev/routers/web/user/setting"
|
user_setting "gitea.dev/routers/web/user/setting"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
org_service "gitea.dev/services/org"
|
org_service "gitea.dev/services/org"
|
||||||
@@ -172,9 +173,14 @@ func Webhooks(ctx *context.Context) {
|
|||||||
|
|
||||||
// DeleteWebhook response for delete webhook
|
// DeleteWebhook response for delete webhook
|
||||||
func DeleteWebhook(ctx *context.Context) {
|
func DeleteWebhook(ctx *context.Context) {
|
||||||
if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil {
|
hook, err := webhook.GetWebhookByOwnerID(ctx, ctx.Org.Organization.ID, ctx.FormInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Flash.Error("GetWebhookByOwnerID: " + err.Error())
|
||||||
|
} else if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Org.Organization.ID, hook.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
|
ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.RecordScoped(ctx, ctx.Org.Organization.AsUser(), nil, audit.WebhookRemove, "webhook", hook.URL)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
shared_audit "gitea.dev/routers/web/shared/audit"
|
||||||
|
"gitea.dev/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ViewAuditLogs(ctx *context.Context) {
|
||||||
|
shared_audit.View(ctx, shared_audit.ViewOptions{
|
||||||
|
Template: "org/settings/audit_logs",
|
||||||
|
ScopeType: audit_model.ScopeOrganization,
|
||||||
|
ScopeID: ctx.Org.Organization.ID,
|
||||||
|
PageData: map[string]any{
|
||||||
|
"PageIsOrgSettings": true,
|
||||||
|
"PageIsSettingsAudit": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ const (
|
|||||||
|
|
||||||
func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers {
|
func newOAuth2CommonHandlers(org *context.Organization) *user_setting.OAuth2CommonHandlers {
|
||||||
return &user_setting.OAuth2CommonHandlers{
|
return &user_setting.OAuth2CommonHandlers{
|
||||||
OwnerID: org.Organization.ID,
|
Owner: org.Organization.AsUser(),
|
||||||
BasePathList: fmt.Sprintf("%s/org/%s/settings/applications", setting.AppSubURL, org.Organization.Name),
|
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),
|
BasePathEditPrefix: fmt.Sprintf("%s/org/%s/settings/applications/oauth2", setting.AppSubURL, org.Organization.Name),
|
||||||
TplAppEdit: tplSettingsOAuthApplicationEdit,
|
TplAppEdit: tplSettingsOAuthApplicationEdit,
|
||||||
|
|||||||
@@ -1385,6 +1385,7 @@ func disableOrEnableWorkflowFile(ctx *context_module.Context, isEnable bool) {
|
|||||||
ctx.ServerError("UpdateRepoUnit", err)
|
ctx.ServerError("UpdateRepoUnit", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actions_service.RecordWorkflowToggle(ctx, ctx.Repo.Repository, workflow, isEnable)
|
||||||
|
|
||||||
if isEnable {
|
if isEnable {
|
||||||
ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow))
|
ctx.Flash.Success(ctx.Tr("actions.workflow.enable_success", workflow))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
issues_model "gitea.dev/models/issues"
|
issues_model "gitea.dev/models/issues"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/web/shared/issue"
|
"gitea.dev/routers/web/shared/issue"
|
||||||
shared_user "gitea.dev/routers/web/shared/user"
|
shared_user "gitea.dev/routers/web/shared/user"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
project_service "gitea.dev/services/projects"
|
project_service "gitea.dev/services/projects"
|
||||||
@@ -134,7 +136,7 @@ func NewProjectPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := project_model.NewProject(ctx, &project_model.Project{
|
project := &project_model.Project{
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
Title: form.Title,
|
Title: form.Title,
|
||||||
Description: form.Content,
|
Description: form.Content,
|
||||||
@@ -142,10 +144,12 @@ func NewProjectPost(ctx *context.Context) {
|
|||||||
TemplateType: form.TemplateType,
|
TemplateType: form.TemplateType,
|
||||||
CardType: form.CardType,
|
CardType: form.CardType,
|
||||||
Type: project_model.TypeRepository,
|
Type: project_model.TypeRepository,
|
||||||
}); err != nil {
|
}
|
||||||
|
if err := project_model.NewProject(ctx, project); err != nil {
|
||||||
ctx.ServerError("NewProject", err)
|
ctx.ServerError("NewProject", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
audit.Record(ctx, audit_model.ProjectCreate, ctx.Repo.Repository, "project", project.Title, "project_id", project.ID)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
|
ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title))
|
||||||
ctx.Redirect(ctx.Repo.RepoLink + "/projects")
|
ctx.Redirect(ctx.Repo.RepoLink + "/projects")
|
||||||
@@ -191,6 +195,7 @@ func DeleteProject(ctx *context.Context) {
|
|||||||
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
|
if err := project_model.DeleteProjectByID(ctx, p.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
|
ctx.Flash.Error("DeleteProjectByID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.Record(ctx, audit_model.ProjectDelete, ctx.Repo.Repository, "project", p.Title, "project_id", p.ID)
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.projects.deletion_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +270,7 @@ func EditProjectPost(ctx *context.Context) {
|
|||||||
ctx.ServerError("UpdateProjects", err)
|
ctx.ServerError("UpdateProjects", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
audit.Record(ctx, audit_model.ProjectUpdate, ctx.Repo.Repository, "project", p.Title, "project_id", p.ID)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
|
ctx.Flash.Success(ctx.Tr("repo.projects.edit_success", p.Title))
|
||||||
if ctx.FormString("redirect") == "project" {
|
if ctx.FormString("redirect") == "project" {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
shared_audit "gitea.dev/routers/web/shared/audit"
|
||||||
|
"gitea.dev/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ViewAuditLogs(ctx *context.Context) {
|
||||||
|
shared_audit.View(ctx, shared_audit.ViewOptions{
|
||||||
|
Template: "repo/settings/audit_logs",
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: ctx.Repo.Repository.ID,
|
||||||
|
PageData: map[string]any{"PageIsSettingsAudit": true},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
asymkey_model "gitea.dev/models/asymkey"
|
asymkey_model "gitea.dev/models/asymkey"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
deploykey_model "gitea.dev/models/deploykey"
|
deploykey_model "gitea.dev/models/deploykey"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
asymkey_service "gitea.dev/services/asymkey"
|
asymkey_service "gitea.dev/services/asymkey"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -68,6 +70,7 @@ func DeployKeysPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryDeployKeyAdd, ctx.Repo.Repository, "deploy_key", key.Name)
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name))
|
ctx.Flash.Success(ctx.Tr("repo.settings.add_key_success", key.Name))
|
||||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/keys")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
git_model "gitea.dev/models/git"
|
git_model "gitea.dev/models/git"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
"gitea.dev/routers/web/repo"
|
"gitea.dev/routers/web/repo"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
pull_service "gitea.dev/services/pull"
|
pull_service "gitea.dev/services/pull"
|
||||||
@@ -149,8 +151,10 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isNewProtectedBranch := false
|
||||||
if protectBranch == nil {
|
if protectBranch == nil {
|
||||||
// No options found, create defaults.
|
// No options found, create defaults.
|
||||||
|
isNewProtectedBranch = true
|
||||||
protectBranch = &git_model.ProtectedBranch{
|
protectBranch = &git_model.ProtectedBranch{
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
RuleName: f.RuleName,
|
RuleName: f.RuleName,
|
||||||
@@ -291,6 +295,12 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isNewProtectedBranch {
|
||||||
|
audit.Record(ctx, audit_model.RepositoryBranchProtectionAdd, ctx.Repo.Repository, "rule", protectBranch.RuleName)
|
||||||
|
} else {
|
||||||
|
audit.Record(ctx, audit_model.RepositoryBranchProtectionUpdate, ctx.Repo.Repository, "rule", protectBranch.RuleName)
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_protect_branch_success", protectBranch.RuleName))
|
||||||
ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName))
|
ctx.Redirect(fmt.Sprintf("%s/settings/branches?rule_name=%s", ctx.Repo.RepoLink, protectBranch.RuleName))
|
||||||
}
|
}
|
||||||
@@ -323,6 +333,8 @@ func DeleteProtectedBranchRulePost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryBranchProtectionRemove, ctx.Repo.Repository, "rule", rule.RuleName)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName))
|
ctx.Flash.Success(ctx.Tr("repo.settings.remove_protected_branch_success", rule.RuleName))
|
||||||
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches")
|
ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings/branches")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
git_model "gitea.dev/models/git"
|
git_model "gitea.dev/models/git"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -65,6 +67,8 @@ func NewProtectedTagPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryTagProtectionAdd, ctx.Repo.Repository, "pattern", pt.NamePattern)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||||
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
|
ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath())
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,8 @@ func EditProtectedTagPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryTagProtectionUpdate, ctx.Repo.Repository, "pattern", pt.NamePattern)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||||
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
||||||
}
|
}
|
||||||
@@ -134,6 +140,8 @@ func DeleteProtectedTagPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryTagProtectionRemove, ctx.Repo.Repository, "pattern", pt.NamePattern)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||||
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/tags")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
shared "gitea.dev/routers/web/shared/secrets"
|
shared "gitea.dev/routers/web/shared/secrets"
|
||||||
@@ -22,6 +24,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type secretsCtx struct {
|
type secretsCtx struct {
|
||||||
|
Owner *user_model.User
|
||||||
|
Repo *repo_model.Repository
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
RepoID int64
|
RepoID int64
|
||||||
IsRepo bool
|
IsRepo bool
|
||||||
@@ -34,7 +38,7 @@ type secretsCtx struct {
|
|||||||
func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
|
func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
|
||||||
if ctx.Data["PageIsRepoSettings"] == true {
|
if ctx.Data["PageIsRepoSettings"] == true {
|
||||||
return &secretsCtx{
|
return &secretsCtx{
|
||||||
OwnerID: 0,
|
Repo: ctx.Repo.Repository,
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
IsRepo: true,
|
IsRepo: true,
|
||||||
SecretsTemplate: tplRepoSecrets,
|
SecretsTemplate: tplRepoSecrets,
|
||||||
@@ -48,8 +52,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
|
|||||||
return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError
|
return nil, nil //nolint:nilnil // error is already handled by ctx.ServerError
|
||||||
}
|
}
|
||||||
return &secretsCtx{
|
return &secretsCtx{
|
||||||
|
Owner: ctx.ContextUser,
|
||||||
OwnerID: ctx.ContextUser.ID,
|
OwnerID: ctx.ContextUser.ID,
|
||||||
RepoID: 0,
|
|
||||||
IsOrg: true,
|
IsOrg: true,
|
||||||
SecretsTemplate: tplOrgSecrets,
|
SecretsTemplate: tplOrgSecrets,
|
||||||
RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets",
|
RedirectLink: ctx.Org.OrgLink + "/settings/actions/secrets",
|
||||||
@@ -58,8 +62,8 @@ func getSecretsCtx(ctx *context.Context) (*secretsCtx, error) {
|
|||||||
|
|
||||||
if ctx.Data["PageIsUserSettings"] == true {
|
if ctx.Data["PageIsUserSettings"] == true {
|
||||||
return &secretsCtx{
|
return &secretsCtx{
|
||||||
|
Owner: ctx.Doer,
|
||||||
OwnerID: ctx.Doer.ID,
|
OwnerID: ctx.Doer.ID,
|
||||||
RepoID: 0,
|
|
||||||
IsUser: true,
|
IsUser: true,
|
||||||
SecretsTemplate: tplUserSecrets,
|
SecretsTemplate: tplUserSecrets,
|
||||||
RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets",
|
RedirectLink: setting.AppSubURL + "/user/settings/actions/secrets",
|
||||||
@@ -105,8 +109,8 @@ func SecretsPost(ctx *context.Context) {
|
|||||||
|
|
||||||
shared.PerformSecretsPost(
|
shared.PerformSecretsPost(
|
||||||
ctx,
|
ctx,
|
||||||
sCtx.OwnerID,
|
sCtx.Owner,
|
||||||
sCtx.RepoID,
|
sCtx.Repo,
|
||||||
sCtx.RedirectLink,
|
sCtx.RedirectLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,8 +123,8 @@ func SecretsDelete(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
shared.PerformSecretsDelete(
|
shared.PerformSecretsDelete(
|
||||||
ctx,
|
ctx,
|
||||||
sCtx.OwnerID,
|
sCtx.Owner,
|
||||||
sCtx.RepoID,
|
sCtx.Repo,
|
||||||
sCtx.RedirectLink,
|
sCtx.RedirectLink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/organization"
|
"gitea.dev/models/organization"
|
||||||
access_model "gitea.dev/models/perm/access"
|
access_model "gitea.dev/models/perm/access"
|
||||||
@@ -31,6 +32,7 @@ import (
|
|||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
repo_router "gitea.dev/routers/web/repo"
|
repo_router "gitea.dev/routers/web/repo"
|
||||||
actions_service "gitea.dev/services/actions"
|
actions_service "gitea.dev/services/actions"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
"gitea.dev/services/migrations"
|
"gitea.dev/services/migrations"
|
||||||
@@ -478,6 +480,8 @@ func handleSettingsPostPushMirrorRemove(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryMirrorPushRemove, repo, "mirror_id", m.ID, "remote_address", m.RemoteAddress)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||||
ctx.Redirect(repo.Link() + "/settings")
|
ctx.Redirect(repo.Link() + "/settings")
|
||||||
}
|
}
|
||||||
@@ -542,6 +546,8 @@ func handleSettingsPostPushMirrorAdd(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryMirrorPushAdd, repo, "mirror_id", m.ID, "remote_address", m.RemoteAddress)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||||
ctx.Redirect(repo.Link() + "/settings")
|
ctx.Redirect(repo.Link() + "/settings")
|
||||||
}
|
}
|
||||||
@@ -724,6 +730,9 @@ func handleSettingsPostSigning(ctx *context.Context) {
|
|||||||
ctx.ServerError("UpdateRepositoryColsNoAutoTime", err)
|
ctx.ServerError("UpdateRepositoryColsNoAutoTime", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositorySigningVerification, repo, "trust_model", repo.TrustModel.String())
|
||||||
|
|
||||||
log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
log.Trace("Repository signing settings updated: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,6 +817,9 @@ func handleSettingsPostConvert(ctx *context.Context) {
|
|||||||
ctx.ServerError("DeleteMirrorByRepoID", err)
|
ctx.ServerError("DeleteMirrorByRepoID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryConvertMirror, repo)
|
||||||
|
|
||||||
log.Trace("Repository converted from mirror to regular: %s", repo.FullName())
|
log.Trace("Repository converted from mirror to regular: %s", repo.FullName())
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed"))
|
||||||
ctx.JSONRedirect(repo.Link())
|
ctx.JSONRedirect(repo.Link())
|
||||||
@@ -1018,6 +1030,8 @@ func handleSettingsPostArchive(ctx *context.Context) {
|
|||||||
// update issue indexer
|
// update issue indexer
|
||||||
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
|
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryArchive, repo)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.archive.success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.archive.success"))
|
||||||
|
|
||||||
log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
||||||
@@ -1046,6 +1060,8 @@ func handleSettingsPostUnarchive(ctx *context.Context) {
|
|||||||
// update issue indexer
|
// update issue indexer
|
||||||
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
|
issue_indexer.UpdateRepoIndexer(ctx, repo.ID)
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryUnarchive, repo)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.unarchive.success"))
|
||||||
|
|
||||||
log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
access_model "gitea.dev/models/perm/access"
|
access_model "gitea.dev/models/perm/access"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/models/webhook"
|
"gitea.dev/models/webhook"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
webhook_module "gitea.dev/modules/webhook"
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
@@ -58,6 +60,8 @@ func Webhooks(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ownerRepoCtx struct {
|
type ownerRepoCtx struct {
|
||||||
|
Owner *user_model.User
|
||||||
|
Repo *repo_model.Repository
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
RepoID int64
|
RepoID int64
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
@@ -71,6 +75,7 @@ type ownerRepoCtx struct {
|
|||||||
func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
|
func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
|
||||||
if ctx.Data["PageIsRepoSettings"] == true {
|
if ctx.Data["PageIsRepoSettings"] == true {
|
||||||
return &ownerRepoCtx{
|
return &ownerRepoCtx{
|
||||||
|
Repo: ctx.Repo.Repository,
|
||||||
RepoID: ctx.Repo.Repository.ID,
|
RepoID: ctx.Repo.Repository.ID,
|
||||||
Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"),
|
Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"),
|
||||||
LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"),
|
LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"),
|
||||||
@@ -80,6 +85,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
|
|||||||
|
|
||||||
if ctx.Data["PageIsOrgSettings"] == true {
|
if ctx.Data["PageIsOrgSettings"] == true {
|
||||||
return &ownerRepoCtx{
|
return &ownerRepoCtx{
|
||||||
|
Owner: ctx.ContextUser,
|
||||||
OwnerID: ctx.ContextUser.ID,
|
OwnerID: ctx.ContextUser.ID,
|
||||||
Link: path.Join(ctx.Org.OrgLink, "settings/hooks"),
|
Link: path.Join(ctx.Org.OrgLink, "settings/hooks"),
|
||||||
LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"),
|
LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"),
|
||||||
@@ -89,6 +95,7 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
|
|||||||
|
|
||||||
if ctx.Data["PageIsUserSettings"] == true {
|
if ctx.Data["PageIsUserSettings"] == true {
|
||||||
return &ownerRepoCtx{
|
return &ownerRepoCtx{
|
||||||
|
Owner: ctx.Doer,
|
||||||
OwnerID: ctx.Doer.ID,
|
OwnerID: ctx.Doer.ID,
|
||||||
Link: path.Join(setting.AppSubURL, "/user/settings/hooks"),
|
Link: path.Join(setting.AppSubURL, "/user/settings/hooks"),
|
||||||
LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"),
|
LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"),
|
||||||
@@ -109,6 +116,14 @@ func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) {
|
|||||||
return nil, errors.New("unable to set OwnerRepo context")
|
return nil, errors.New("unable to set OwnerRepo context")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordWebhookAudit emits a webhook audit event scoped to the repository,
|
||||||
|
// organization, user, or instance (admin/system) the webhook belongs to. The
|
||||||
|
// shared add/edit handlers run in any of these contexts, so the scope is derived
|
||||||
|
// from orCtx rather than assuming a repository.
|
||||||
|
func (orCtx *ownerRepoCtx) recordWebhookAudit(ctx *context.Context, actions audit.ScopedActions, url string) {
|
||||||
|
audit.RecordScoped(ctx, orCtx.Owner, orCtx.Repo, actions, "webhook", url)
|
||||||
|
}
|
||||||
|
|
||||||
func checkHookType(ctx *context.Context) string {
|
func checkHookType(ctx *context.Context) string {
|
||||||
hookType := strings.ToLower(ctx.PathParam("type"))
|
hookType := strings.ToLower(ctx.PathParam("type"))
|
||||||
if !util.SliceContainsString(setting.Webhook.Types, hookType, true) {
|
if !util.SliceContainsString(setting.Webhook.Types, hookType, true) {
|
||||||
@@ -258,6 +273,8 @@ func createWebhook(ctx *context.Context, params webhookParams) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
orCtx.recordWebhookAudit(ctx, audit.WebhookAdd, w.URL)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.add_hook_success"))
|
||||||
ctx.Redirect(orCtx.Link)
|
ctx.Redirect(orCtx.Link)
|
||||||
}
|
}
|
||||||
@@ -311,6 +328,8 @@ func editWebhook(ctx *context.Context, params webhookParams) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
orCtx.recordWebhookAudit(ctx, audit.WebhookUpdate, w.URL)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_hook_success"))
|
||||||
ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID))
|
ctx.Redirect(fmt.Sprintf("%s/%d", orCtx.Link, w.ID))
|
||||||
}
|
}
|
||||||
@@ -736,9 +755,14 @@ func ReplayWebhook(ctx *context.Context) {
|
|||||||
|
|
||||||
// DeleteWebhook delete a webhook
|
// DeleteWebhook delete a webhook
|
||||||
func DeleteWebhook(ctx *context.Context) {
|
func DeleteWebhook(ctx *context.Context) {
|
||||||
if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id")); err != nil {
|
hook, err := webhook.GetWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Flash.Error("GetWebhookByRepoID: " + err.Error())
|
||||||
|
} else if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, hook.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error())
|
ctx.Flash.Error("DeleteWebhookByRepoID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.RecordScoped(ctx, nil, ctx.Repo.Repository, audit.WebhookRemove, "webhook", hook.URL)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
"gitea.dev/models/db"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/templates"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
|
"gitea.dev/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ViewOptions configures a scoped audit log listing. The admin view leaves
|
||||||
|
// ScopeType empty to list every event; the user/org/repo views constrain the
|
||||||
|
// query to their own scope.
|
||||||
|
type ViewOptions struct {
|
||||||
|
Template templates.TplName
|
||||||
|
ScopeType audit_model.ScopeType
|
||||||
|
ScopeID int64
|
||||||
|
// PageData holds ctx.Data flags to enable for the active navigation tab.
|
||||||
|
PageData map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterableOrigins = []audit_model.Origin{audit_model.OriginUI, audit_model.OriginAPI, audit_model.OriginCLI, audit_model.OriginSystem}
|
||||||
|
|
||||||
|
// SearchOptionsFromRequest builds the event query from the request filters and
|
||||||
|
// publishes the applied values to ctx.Data so the filter form can render its
|
||||||
|
// current state. The listing and the export share it to stay in sync.
|
||||||
|
func SearchOptionsFromRequest(ctx *context.Context, scopeType audit_model.ScopeType, scopeID int64) *audit_model.EventSearchOptions {
|
||||||
|
// only the two known sort values are accepted, anything else falls back to the default
|
||||||
|
sort := util.Iif(audit_model.EventSort(ctx.FormString("sort")) == audit_model.SortTimestampAsc, audit_model.SortTimestampAsc, audit_model.SortTimestampDesc)
|
||||||
|
|
||||||
|
opts := &audit_model.EventSearchOptions{
|
||||||
|
Sort: sort,
|
||||||
|
ScopeType: scopeType,
|
||||||
|
ScopeID: scopeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if action := audit_model.Action(ctx.FormString("action")); action != "" {
|
||||||
|
if _, ok := audit_model.MessageTemplate(action); ok {
|
||||||
|
opts.Action = action
|
||||||
|
} else if audit_model.IsActionFilter(action) {
|
||||||
|
opts.ActionPrefix = action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if origin := audit_model.Origin(ctx.FormString("origin")); slices.Contains(filterableOrigins, origin) {
|
||||||
|
opts.Origin = origin
|
||||||
|
}
|
||||||
|
if actor := ctx.FormTrim("actor"); actor != "" {
|
||||||
|
u, err := user_model.GetUserByName(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
opts.ActorID = -1 // an unknown actor matches nothing rather than everything
|
||||||
|
} else {
|
||||||
|
opts.ActorID = u.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["AuditSort"] = string(opts.Sort)
|
||||||
|
ctx.Data["AuditFilterAction"] = string(opts.Action)
|
||||||
|
if opts.ActionPrefix != "" {
|
||||||
|
ctx.Data["AuditFilterAction"] = string(opts.ActionPrefix)
|
||||||
|
}
|
||||||
|
ctx.Data["AuditFilterOrigin"] = string(opts.Origin)
|
||||||
|
ctx.Data["AuditFilterActor"] = ctx.FormTrim("actor")
|
||||||
|
ctx.Data["AuditActions"] = audit_model.ActionFilters()
|
||||||
|
ctx.Data["AuditOrigins"] = filterableOrigins
|
||||||
|
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders a paginated audit log listing shared by the admin, user, org and
|
||||||
|
// repo settings pages. Only the scope filter, template and page flags differ.
|
||||||
|
func View(ctx *context.Context, opts ViewOptions) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("audit.title")
|
||||||
|
ctx.Data["AuditRecordEnabled"] = setting.AuditRecordEnabled()
|
||||||
|
maps.Copy(ctx.Data, opts.PageData)
|
||||||
|
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
|
|
||||||
|
searchOpts := SearchOptionsFromRequest(ctx, opts.ScopeType, opts.ScopeID)
|
||||||
|
searchOpts.ListOptions = db.ListOptions{
|
||||||
|
Page: page,
|
||||||
|
PageSize: setting.UI.Admin.NoticePagingNum,
|
||||||
|
}
|
||||||
|
|
||||||
|
evs, total, err := audit.FindEvents(ctx, searchOpts)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("FindEvents", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["AuditEvents"] = evs
|
||||||
|
|
||||||
|
ctx.Data["Page"] = context.NewPagerBuilder(ctx).TotalCount(total).PerPageLimit(setting.UI.Admin.NoticePagingNum).CurPage(page).Build()
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, opts.Template)
|
||||||
|
}
|
||||||
@@ -5,10 +5,13 @@ package secrets
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
secret_model "gitea.dev/models/secret"
|
secret_model "gitea.dev/models/secret"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
secret_service "gitea.dev/services/secrets"
|
secret_service "gitea.dev/services/secrets"
|
||||||
@@ -26,29 +29,49 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) {
|
|||||||
ctx.Data["DescriptionMaxLength"] = secret_model.SecretDescriptionMaxLength
|
ctx.Data["DescriptionMaxLength"] = secret_model.SecretDescriptionMaxLength
|
||||||
}
|
}
|
||||||
|
|
||||||
func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
|
func secretOwnerRepoIDs(owner *user_model.User, repo *repo_model.Repository) (ownerID, repoID int64) {
|
||||||
form := web.GetForm[*forms.AddSecretForm](ctx)
|
if owner != nil {
|
||||||
|
ownerID = owner.ID
|
||||||
|
}
|
||||||
|
if repo != nil {
|
||||||
|
repoID = repo.ID
|
||||||
|
}
|
||||||
|
return ownerID, repoID
|
||||||
|
}
|
||||||
|
|
||||||
s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description)
|
func PerformSecretsPost(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository, redirectURL string) {
|
||||||
|
form := web.GetForm[*forms.AddSecretForm](ctx)
|
||||||
|
ownerID, repoID := secretOwnerRepoIDs(owner, repo)
|
||||||
|
|
||||||
|
s, created, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSONErrorAuto(err)
|
ctx.JSONErrorAuto(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actions := audit.SecretUpdate
|
||||||
|
if created {
|
||||||
|
actions = audit.SecretAdd
|
||||||
|
}
|
||||||
|
audit.RecordScoped(ctx, owner, repo, actions, "secret", s.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("secrets.save_success", s.Name))
|
ctx.Flash.Success(ctx.Tr("secrets.save_success", s.Name))
|
||||||
ctx.JSONRedirect(redirectURL)
|
ctx.JSONRedirect(redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PerformSecretsDelete(ctx *context.Context, ownerID, repoID int64, redirectURL string) {
|
func PerformSecretsDelete(ctx *context.Context, owner *user_model.User, repo *repo_model.Repository, redirectURL string) {
|
||||||
id := ctx.FormInt64("id")
|
id := ctx.FormInt64("id")
|
||||||
|
ownerID, repoID := secretOwnerRepoIDs(owner, repo)
|
||||||
|
|
||||||
err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id)
|
s, err := secret_service.DeleteSecretByID(ctx, ownerID, repoID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("DeleteSecretByID(%d) failed: %v", id, err)
|
log.Error("DeleteSecretByID(%d) failed: %v", id, err)
|
||||||
ctx.JSONError(ctx.Tr("secrets.deletion.failed"))
|
ctx.JSONError(ctx.Tr("secrets.deletion.failed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.RecordScoped(ctx, owner, repo, audit.SecretRemove, "secret", s.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
|
ctx.Flash.Success(ctx.Tr("secrets.deletion.success"))
|
||||||
ctx.JSONRedirect(redirectURL)
|
ctx.JSONRedirect(redirectURL)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -104,6 +106,8 @@ func ApplicationsPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.Doer, "token", t.Name, "token_scope", t.Scope)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
|
ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
|
||||||
ctx.Flash.Info(t.Token)
|
ctx.Flash.Info(t.Token)
|
||||||
|
|
||||||
@@ -112,9 +116,14 @@ func ApplicationsPost(ctx *context.Context) {
|
|||||||
|
|
||||||
// DeleteApplication response for delete user access token
|
// DeleteApplication response for delete user access token
|
||||||
func DeleteApplication(ctx *context.Context) {
|
func DeleteApplication(ctx *context.Context) {
|
||||||
if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
|
t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Flash.Error("GetAccessTokenByID: " + err.Error())
|
||||||
|
} else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.Doer.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
|
ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.Doer, "token", t.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
|
ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
shared_audit "gitea.dev/routers/web/shared/audit"
|
||||||
|
"gitea.dev/services/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ViewAuditLogs(ctx *context.Context) {
|
||||||
|
shared_audit.View(ctx, shared_audit.ViewOptions{
|
||||||
|
Template: "user/settings/audit_logs",
|
||||||
|
ScopeType: audit_model.ScopeUser,
|
||||||
|
ScopeID: ctx.Doer.ID,
|
||||||
|
PageData: map[string]any{"PageIsSettingsAudit": true},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
asymkey_model "gitea.dev/models/asymkey"
|
asymkey_model "gitea.dev/models/asymkey"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
asymkey_service "gitea.dev/services/asymkey"
|
asymkey_service "gitea.dev/services/asymkey"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -68,7 +70,8 @@ func KeysPost(ctx *context.Context) {
|
|||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||||
return
|
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
|
ctx.Data["HasPrincipalError"] = true
|
||||||
switch {
|
switch {
|
||||||
case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
case asymkey_model.IsErrKeyAlreadyExist(err), asymkey_model.IsErrKeyNameAlreadyUsed(err):
|
||||||
@@ -81,6 +84,9 @@ func KeysPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserKeyPrincipalAdd, ctx.Doer, "key", key.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
|
ctx.Flash.Success(ctx.Tr("settings.add_principal_success", form.Content))
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||||
case "gpg":
|
case "gpg":
|
||||||
@@ -135,6 +141,8 @@ func KeysPost(ctx *context.Context) {
|
|||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
keyIDs += key.KeyID
|
keyIDs += key.KeyID
|
||||||
keyIDs += ", "
|
keyIDs += ", "
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserKeyGPGAdd, ctx.Doer, "gpg_key_id", key.KeyID)
|
||||||
}
|
}
|
||||||
if len(keyIDs) > 0 {
|
if len(keyIDs) > 0 {
|
||||||
keyIDs = keyIDs[:len(keyIDs)-2]
|
keyIDs = keyIDs[:len(keyIDs)-2]
|
||||||
@@ -189,7 +197,8 @@ func KeysPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0, false); err != nil {
|
key, err := asymkey_model.AddPublicKey(ctx, ctx.Doer.ID, form.Title, content, 0, false)
|
||||||
|
if err != nil {
|
||||||
ctx.Data["HasSSHError"] = true
|
ctx.Data["HasSSHError"] = true
|
||||||
switch {
|
switch {
|
||||||
case asymkey_model.IsErrKeyAlreadyExist(err):
|
case asymkey_model.IsErrKeyAlreadyExist(err):
|
||||||
@@ -210,6 +219,9 @@ func KeysPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserKeySSHAdd, ctx.Doer, "fingerprint", key.Fingerprint)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title))
|
ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title))
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
|
||||||
case "verify_ssh":
|
case "verify_ssh":
|
||||||
@@ -256,10 +268,18 @@ func DeleteKey(ctx *context.Context) {
|
|||||||
ctx.JSONError("gpg keys setting is not allowed to be visited")
|
ctx.JSONError("gpg keys setting is not allowed to be visited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
key, err := asymkey_model.GetGPGKeyForUserByID(ctx, ctx.Doer.ID, ctx.FormInt64("id"))
|
||||||
|
if err != nil && !asymkey_model.IsErrGPGKeyNotExist(err) {
|
||||||
|
ctx.ServerError("GetGPGKeyForUserByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
|
if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.FormInt64("id")); err != nil {
|
||||||
ctx.JSONError("Failed to delete PGP key")
|
ctx.JSONError("Failed to delete PGP key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if key != nil {
|
||||||
|
audit.Record(ctx, audit_model.UserKeyGPGRemove, ctx.Doer, "gpg_key_id", key.KeyID)
|
||||||
|
}
|
||||||
ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
|
ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success"))
|
||||||
case "ssh":
|
case "ssh":
|
||||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
|
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package setting
|
package setting
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
@@ -13,9 +14,9 @@ const (
|
|||||||
tplSettingsOAuthApplicationEdit templates.TplName = "user/settings/applications_oauth2_edit"
|
tplSettingsOAuthApplicationEdit templates.TplName = "user/settings/applications_oauth2_edit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newOAuth2CommonHandlers(userID int64) *OAuth2CommonHandlers {
|
func newOAuth2CommonHandlers(owner *user_model.User) *OAuth2CommonHandlers {
|
||||||
return &OAuth2CommonHandlers{
|
return &OAuth2CommonHandlers{
|
||||||
OwnerID: userID,
|
Owner: owner,
|
||||||
BasePathList: setting.AppSubURL + "/user/settings/applications",
|
BasePathList: setting.AppSubURL + "/user/settings/applications",
|
||||||
BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2",
|
BasePathEditPrefix: setting.AppSubURL + "/user/settings/applications/oauth2",
|
||||||
TplAppEdit: tplSettingsOAuthApplicationEdit,
|
TplAppEdit: tplSettingsOAuthApplicationEdit,
|
||||||
@@ -27,7 +28,7 @@ func OAuthApplicationsPost(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||||
ctx.Data["PageIsSettingsApplications"] = true
|
ctx.Data["PageIsSettingsApplications"] = true
|
||||||
|
|
||||||
oa := newOAuth2CommonHandlers(ctx.Doer.ID)
|
oa := newOAuth2CommonHandlers(ctx.Doer)
|
||||||
oa.AddApp(ctx)
|
oa.AddApp(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ func OAuthApplicationsEdit(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||||
ctx.Data["PageIsSettingsApplications"] = true
|
ctx.Data["PageIsSettingsApplications"] = true
|
||||||
|
|
||||||
oa := newOAuth2CommonHandlers(ctx.Doer.ID)
|
oa := newOAuth2CommonHandlers(ctx.Doer)
|
||||||
oa.EditSave(ctx)
|
oa.EditSave(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,24 +46,24 @@ func OAuthApplicationsRegenerateSecret(ctx *context.Context) {
|
|||||||
ctx.Data["Title"] = ctx.Tr("settings_title")
|
ctx.Data["Title"] = ctx.Tr("settings_title")
|
||||||
ctx.Data["PageIsSettingsApplications"] = true
|
ctx.Data["PageIsSettingsApplications"] = true
|
||||||
|
|
||||||
oa := newOAuth2CommonHandlers(ctx.Doer.ID)
|
oa := newOAuth2CommonHandlers(ctx.Doer)
|
||||||
oa.RegenerateSecret(ctx)
|
oa.RegenerateSecret(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2ApplicationShow displays the given application
|
// OAuth2ApplicationShow displays the given application
|
||||||
func OAuth2ApplicationShow(ctx *context.Context) {
|
func OAuth2ApplicationShow(ctx *context.Context) {
|
||||||
oa := newOAuth2CommonHandlers(ctx.Doer.ID)
|
oa := newOAuth2CommonHandlers(ctx.Doer)
|
||||||
oa.EditShow(ctx)
|
oa.EditShow(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteOAuth2Application deletes the given oauth2 application
|
// DeleteOAuth2Application deletes the given oauth2 application
|
||||||
func DeleteOAuth2Application(ctx *context.Context) {
|
func DeleteOAuth2Application(ctx *context.Context) {
|
||||||
oa := newOAuth2CommonHandlers(ctx.Doer.ID)
|
oa := newOAuth2CommonHandlers(ctx.Doer)
|
||||||
oa.DeleteApp(ctx)
|
oa.DeleteApp(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeOAuth2Grant revokes the grant with the given id
|
// RevokeOAuth2Grant revokes the grant with the given id
|
||||||
func RevokeOAuth2Grant(ctx *context.Context) {
|
func RevokeOAuth2Grant(ctx *context.Context) {
|
||||||
oa := newOAuth2CommonHandlers(ctx.Doer.ID)
|
oa := newOAuth2CommonHandlers(ctx.Doer)
|
||||||
oa.RevokeGrant(ctx)
|
oa.RevokeGrant(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,36 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
shared_user "gitea.dev/routers/web/shared/user"
|
shared_user "gitea.dev/routers/web/shared/user"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuth2CommonHandlers struct {
|
type OAuth2CommonHandlers struct {
|
||||||
OwnerID int64 // 0 for instance-wide, otherwise OrgID or UserID
|
Owner *user_model.User // nil for instance-wide, otherwise the Org or User owning the applications
|
||||||
BasePathList string // the base URL for the application list page, eg: "/user/setting/applications"
|
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"
|
BasePathEditPrefix string // the base URL for the application edit page, will be appended with app id, eg: "/user/setting/applications/oauth2"
|
||||||
TplAppEdit templates.TplName // the template for the application edit page
|
TplAppEdit templates.TplName // the template for the application edit page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (oa *OAuth2CommonHandlers) ownerID() int64 {
|
||||||
|
if oa.Owner != nil {
|
||||||
|
return oa.Owner.ID
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit emits an OAuth2 application audit event scoped to the owner, which
|
||||||
|
// is nil for instance-wide (admin) applications, an organization, or a user.
|
||||||
|
func (oa *OAuth2CommonHandlers) recordAudit(ctx *context.Context, actions audit.ScopedActions, appName string) {
|
||||||
|
audit.RecordScoped(ctx, oa.Owner, nil, actions, "oauth2_application", appName)
|
||||||
|
}
|
||||||
|
|
||||||
func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context, app *auth.OAuth2Application) {
|
func (oa *OAuth2CommonHandlers) renderEditPage(ctx *context.Context, app *auth.OAuth2Application) {
|
||||||
ctx.Data["App"] = app
|
ctx.Data["App"] = app
|
||||||
ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID)
|
ctx.Data["FormActionPath"] = fmt.Sprintf("%s/%d", oa.BasePathEditPrefix, app.ID)
|
||||||
@@ -50,7 +65,7 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
|
|||||||
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
|
app, err := auth.CreateOAuth2Application(ctx, auth.CreateOAuth2ApplicationOptions{
|
||||||
Name: form.Name,
|
Name: form.Name,
|
||||||
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
|
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
|
||||||
UserID: oa.OwnerID,
|
UserID: oa.ownerID(),
|
||||||
ConfidentialClient: form.ConfidentialClient,
|
ConfidentialClient: form.ConfidentialClient,
|
||||||
SkipSecondaryAuthorization: form.SkipSecondaryAuthorization,
|
SkipSecondaryAuthorization: form.SkipSecondaryAuthorization,
|
||||||
})
|
})
|
||||||
@@ -59,6 +74,8 @@ func (oa *OAuth2CommonHandlers) AddApp(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oa.recordAudit(ctx, audit.OAuth2ApplicationAdd, app.Name)
|
||||||
|
|
||||||
// render the edit page with secret
|
// render the edit page with secret
|
||||||
ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true)
|
ctx.Flash.Success(ctx.Tr("settings.create_oauth2_application_success"), true)
|
||||||
ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx)
|
ctx.Data["ClientSecret"], err = app.GenerateClientSecret(ctx)
|
||||||
@@ -81,7 +98,7 @@ func (oa *OAuth2CommonHandlers) EditShow(ctx *context.Context) {
|
|||||||
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if app.UID != oa.OwnerID {
|
if app.UID != oa.ownerID() {
|
||||||
ctx.NotFound(nil)
|
ctx.NotFound(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -102,7 +119,7 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
|
|||||||
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if app.UID != oa.OwnerID {
|
if app.UID != oa.ownerID() {
|
||||||
ctx.NotFound(nil)
|
ctx.NotFound(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -110,18 +127,21 @@ func (oa *OAuth2CommonHandlers) EditSave(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
updatedApp, err := auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
|
||||||
if ctx.Data["App"], err = auth.UpdateOAuth2Application(ctx, auth.UpdateOAuth2ApplicationOptions{
|
|
||||||
ID: ctx.PathParamInt64("id"),
|
ID: ctx.PathParamInt64("id"),
|
||||||
Name: form.Name,
|
Name: form.Name,
|
||||||
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
|
RedirectURIs: util.SplitTrimSpace(form.RedirectURIs, "\n"),
|
||||||
UserID: oa.OwnerID,
|
UserID: oa.ownerID(),
|
||||||
ConfidentialClient: form.ConfidentialClient,
|
ConfidentialClient: form.ConfidentialClient,
|
||||||
SkipSecondaryAuthorization: form.SkipSecondaryAuthorization,
|
SkipSecondaryAuthorization: form.SkipSecondaryAuthorization,
|
||||||
}); err != nil {
|
})
|
||||||
|
if err != nil {
|
||||||
ctx.ServerError("UpdateOAuth2Application", err)
|
ctx.ServerError("UpdateOAuth2Application", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oa.recordAudit(ctx, audit.OAuth2ApplicationUpdate, updatedApp.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"))
|
ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"))
|
||||||
ctx.Redirect(oa.BasePathList)
|
ctx.Redirect(oa.BasePathList)
|
||||||
}
|
}
|
||||||
@@ -137,7 +157,7 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) {
|
|||||||
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
ctx.ServerError("GetOAuth2ApplicationByID", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if app.UID != oa.OwnerID {
|
if app.UID != oa.ownerID() {
|
||||||
ctx.NotFound(nil)
|
ctx.NotFound(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,28 +166,59 @@ func (oa *OAuth2CommonHandlers) RegenerateSecret(ctx *context.Context) {
|
|||||||
ctx.ServerError("GenerateClientSecret", err)
|
ctx.ServerError("GenerateClientSecret", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oa.recordAudit(ctx, audit.OAuth2ApplicationSecret, app.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true)
|
ctx.Flash.Success(ctx.Tr("settings.update_oauth2_application_success"), true)
|
||||||
oa.renderEditPage(ctx, app)
|
oa.renderEditPage(ctx, app)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteApp deletes the given oauth2 application
|
// DeleteApp deletes the given oauth2 application
|
||||||
func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) {
|
func (oa *OAuth2CommonHandlers) DeleteApp(ctx *context.Context) {
|
||||||
if err := auth.DeleteOAuth2Application(ctx, ctx.PathParamInt64("id"), oa.OwnerID); err != nil {
|
app, err := auth.GetOAuth2ApplicationByID(ctx, ctx.PathParamInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFoundOrServerError("GetOAuth2ApplicationByID", auth.IsErrOAuthApplicationNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.DeleteOAuth2Application(ctx, app.ID, oa.ownerID()); err != nil {
|
||||||
ctx.ServerError("DeleteOAuth2Application", err)
|
ctx.ServerError("DeleteOAuth2Application", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oa.recordAudit(ctx, audit.OAuth2ApplicationRemove, app.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success"))
|
ctx.Flash.Success(ctx.Tr("settings.remove_oauth2_application_success"))
|
||||||
ctx.JSONRedirect(oa.BasePathList)
|
ctx.JSONRedirect(oa.BasePathList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeGrant revokes the grant
|
// RevokeGrant revokes the grant
|
||||||
func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) {
|
func (oa *OAuth2CommonHandlers) RevokeGrant(ctx *context.Context) {
|
||||||
if err := auth.RevokeOAuth2Grant(ctx, ctx.PathParamInt64("grantId"), oa.OwnerID); err != nil {
|
grant, err := auth.GetOAuth2GrantByID(ctx, ctx.PathParamInt64("grantId"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetOAuth2GrantByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// grants belong to individual users, so this also rejects the instance-wide
|
||||||
|
// (owner nil, ID 0) and organization handlers without assuming who routes here
|
||||||
|
if grant == nil || oa.Owner == nil || grant.UserID != oa.Owner.ID {
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFoundOrServerError("GetOAuth2ApplicationByID", auth.IsErrOAuthApplicationNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.RevokeOAuth2Grant(ctx, grant.ID, oa.ownerID()); err != nil {
|
||||||
ctx.ServerError("RevokeOAuth2Grant", err)
|
ctx.ServerError("RevokeOAuth2Grant", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oa.recordAudit(ctx, audit.OAuth2ApplicationRevoke, app.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success"))
|
ctx.Flash.Success(ctx.Tr("settings.revoke_oauth2_grant_success"))
|
||||||
ctx.JSONRedirect(oa.BasePathList)
|
ctx.JSONRedirect(oa.BasePathList)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
|
|
||||||
@@ -57,6 +59,8 @@ func RegenerateScratchTwoFactor(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserTwoFactorRegenerate, ctx.Doer, "two_factor_id", t.ID)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token))
|
ctx.Flash.Success(ctx.Tr("settings.twofa_scratch_token_regenerated", token))
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,8 @@ func DisableTwoFactor(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserTwoFactorDisable, ctx.Doer, "two_factor_id", t.ID)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
|
ctx.Flash.Success(ctx.Tr("settings.twofa_disabled"))
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||||
}
|
}
|
||||||
@@ -273,6 +279,8 @@ func EnrollTwoFactorPost(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserTwoFactorEnable, ctx.Doer)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token))
|
ctx.Flash.Success(ctx.Tr("settings.twofa_enrolled", token))
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/openid"
|
"gitea.dev/modules/auth/openid"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
)
|
)
|
||||||
@@ -105,6 +107,9 @@ func settingsOpenIDVerify(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name)
|
log.Trace("Associated OpenID %s to user %s", id, ctx.Doer.Name)
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserOpenIDAdd, ctx.Doer, "openid", oid.URI)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
|
ctx.Flash.Success(ctx.Tr("settings.add_openid_success"))
|
||||||
|
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/security")
|
||||||
@@ -117,7 +122,17 @@ func DeleteOpenID(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := user_model.DeleteUserOpenID(ctx, &user_model.UserOpenID{ID: ctx.FormInt64("id"), UID: ctx.Doer.ID}); err != nil {
|
oid, err := user_model.GetUserOpenIDByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
|
ctx.HTTPError(http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
ctx.ServerError("GetUserOpenIDByID", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user_model.DeleteUserOpenID(ctx, oid); err != nil {
|
||||||
if errors.Is(err, util.ErrNotExist) {
|
if errors.Is(err, util.ErrNotExist) {
|
||||||
ctx.HTTPError(http.StatusNotFound)
|
ctx.HTTPError(http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
@@ -125,6 +140,9 @@ func DeleteOpenID(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserOpenIDRemove, ctx.Doer, "openid", oid.URI)
|
||||||
|
|
||||||
log.Trace("OpenID address deleted: %s", ctx.Doer.Name)
|
log.Trace("OpenID address deleted: %s", ctx.Doer.Name)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success"))
|
ctx.Flash.Success(ctx.Tr("settings.openid_deletion_success"))
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/auth/source/oauth2"
|
"gitea.dev/services/auth/source/oauth2"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
)
|
)
|
||||||
@@ -58,6 +60,8 @@ func DeleteAccountLink(ctx *context.Context) {
|
|||||||
if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil {
|
if _, err := user_model.RemoveAccountLink(ctx, ctx.Doer, id); err != nil {
|
||||||
ctx.Flash.Error("RemoveAccountLink: " + err.Error())
|
ctx.Flash.Error("RemoveAccountLink: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.Record(ctx, audit_model.UserExternalLoginRemove, ctx.Doer, "auth_source_id", id)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success"))
|
ctx.Flash.Success(ctx.Tr("settings.remove_account_link_success"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
wa "gitea.dev/modules/auth/webauthn"
|
wa "gitea.dev/modules/auth/webauthn"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/web"
|
"gitea.dev/modules/web"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
"gitea.dev/services/forms"
|
"gitea.dev/services/forms"
|
||||||
|
|
||||||
@@ -124,13 +126,16 @@ func WebauthnRegisterPost(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the credential
|
// 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 {
|
if err != nil {
|
||||||
ctx.ServerError("CreateCredential", err)
|
ctx.ServerError("CreateCredential", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = ctx.Session.Delete("webauthnName")
|
_ = ctx.Session.Delete("webauthnName")
|
||||||
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
_ = ctx.Session.Set(session.KeyUserHasTwoFactorAuth, true)
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserWebAuthAdd, ctx.Doer, "credential", dbCred.Name)
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, cred)
|
ctx.JSON(http.StatusCreated, cred)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +146,17 @@ func WebauthnDelete(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := auth.DeleteCredential(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
|
cred, err := auth.GetWebAuthnCredentialByID(ctx, ctx.FormInt64("id"))
|
||||||
ctx.ServerError("GetWebAuthnCredentialByID", err)
|
if err != nil {
|
||||||
|
ctx.NotFoundOrServerError("GetWebAuthnCredentialByID", auth.IsErrWebAuthnCredentialNotExist, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ok, err := auth.DeleteCredential(ctx, cred.ID, ctx.Doer.ID); err != nil {
|
||||||
|
ctx.ServerError("DeleteCredential", err)
|
||||||
|
return
|
||||||
|
} else if ok {
|
||||||
|
audit.Record(ctx, audit_model.UserWebAuthRemove, ctx.Doer, "credential", cred.Name)
|
||||||
|
}
|
||||||
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
|
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/security")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"gitea.dev/models/webhook"
|
"gitea.dev/models/webhook"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/templates"
|
"gitea.dev/modules/templates"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +38,14 @@ func Webhooks(ctx *context.Context) {
|
|||||||
|
|
||||||
// DeleteWebhook response for delete webhook
|
// DeleteWebhook response for delete webhook
|
||||||
func DeleteWebhook(ctx *context.Context) {
|
func DeleteWebhook(ctx *context.Context) {
|
||||||
if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id")); err != nil {
|
hook, err := webhook.GetWebhookByOwnerID(ctx, ctx.Doer.ID, ctx.FormInt64("id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Flash.Error("GetWebhookByOwnerID: " + err.Error())
|
||||||
|
} else if err := webhook.DeleteWebhookByOwnerID(ctx, ctx.Doer.ID, hook.ID); err != nil {
|
||||||
ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
|
ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error())
|
||||||
} else {
|
} else {
|
||||||
|
audit.RecordScoped(ctx, ctx.Doer, nil, audit.WebhookRemove, "webhook", hook.URL)
|
||||||
|
|
||||||
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
"gitea.dev/models/unit"
|
"gitea.dev/models/unit"
|
||||||
@@ -33,6 +34,7 @@ import (
|
|||||||
"gitea.dev/routers/web/healthcheck"
|
"gitea.dev/routers/web/healthcheck"
|
||||||
"gitea.dev/routers/web/misc"
|
"gitea.dev/routers/web/misc"
|
||||||
"gitea.dev/routers/web/org"
|
"gitea.dev/routers/web/org"
|
||||||
|
org_setting "gitea.dev/routers/web/org/setting"
|
||||||
"gitea.dev/routers/web/repo"
|
"gitea.dev/routers/web/repo"
|
||||||
"gitea.dev/routers/web/repo/actions"
|
"gitea.dev/routers/web/repo/actions"
|
||||||
repo_setting "gitea.dev/routers/web/repo/setting"
|
repo_setting "gitea.dev/routers/web/repo/setting"
|
||||||
@@ -305,7 +307,7 @@ func Routes() *web.Router {
|
|||||||
routes.Get("/ssh_info", misc.SSHInfo)
|
routes.Get("/ssh_info", misc.SSHInfo)
|
||||||
routes.Get("/api/healthz", healthcheck.Check)
|
routes.Get("/api/healthz", healthcheck.Check)
|
||||||
|
|
||||||
mid = append(mid, common.MustInitSessioner(), context.Contexter())
|
mid = append(mid, common.MustInitSessioner(), context.Contexter(), common.AuditOrigin(audit_model.OriginUI))
|
||||||
|
|
||||||
// Get user from session if logged in.
|
// Get user from session if logged in.
|
||||||
webAuth := newWebAuthMiddleware()
|
webAuth := newWebAuthMiddleware()
|
||||||
@@ -748,6 +750,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
addWebhookEditRoutes()
|
addWebhookEditRoutes()
|
||||||
}, webhooksEnabled)
|
}, webhooksEnabled)
|
||||||
|
|
||||||
|
m.Get("/audit_logs", user_setting.ViewAuditLogs)
|
||||||
|
|
||||||
m.Group("/blocked_users", func() {
|
m.Group("/blocked_users", func() {
|
||||||
m.Get("", user_setting.BlockedUsers)
|
m.Get("", user_setting.BlockedUsers)
|
||||||
m.Post("", web.Bind[*forms.BlockUserForm](), user_setting.BlockedUsersPost)
|
m.Post("", web.Bind[*forms.BlockUserForm](), user_setting.BlockedUsersPost)
|
||||||
@@ -795,6 +799,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
m.Group("/monitor", func() {
|
m.Group("/monitor", func() {
|
||||||
|
m.Get("/audit_logs", admin.ViewAuditLogs)
|
||||||
|
m.Get("/audit_logs/export", admin.ExportAuditLogs)
|
||||||
m.Get("/stats", admin.MonitorStats)
|
m.Get("/stats", admin.MonitorStats)
|
||||||
m.Get("/cron", admin.CronTasks)
|
m.Get("/cron", admin.CronTasks)
|
||||||
m.Get("/perftrace", admin.PerfTrace)
|
m.Get("/perftrace", admin.PerfTrace)
|
||||||
@@ -1056,6 +1062,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
addSettingsScopedWorkflowsRoutes()
|
addSettingsScopedWorkflowsRoutes()
|
||||||
}, actions.MustEnableActions)
|
}, actions.MustEnableActions)
|
||||||
|
|
||||||
|
m.Get("/audit_logs", org_setting.ViewAuditLogs)
|
||||||
|
|
||||||
m.Post("/rename", web.Bind[*forms.RenameOrgForm](), org.SettingsRenamePost)
|
m.Post("/rename", web.Bind[*forms.RenameOrgForm](), org.SettingsRenamePost)
|
||||||
m.Post("/delete", org.SettingsDeleteOrgPost)
|
m.Post("/delete", org.SettingsDeleteOrgPost)
|
||||||
m.Post("/visibility", org.SettingsChangeVisibilityPost)
|
m.Post("/visibility", org.SettingsChangeVisibilityPost)
|
||||||
@@ -1267,6 +1275,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||||||
m.Post("/token_permissions", repo_setting.UpdateTokenPermissions)
|
m.Post("/token_permissions", repo_setting.UpdateTokenPermissions)
|
||||||
})
|
})
|
||||||
}, actions.MustEnableActions)
|
}, actions.MustEnableActions)
|
||||||
|
m.Get("/audit_logs", repo_setting.ViewAuditLogs)
|
||||||
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
|
// the follow handler must be under "settings", otherwise this incomplete repo can't be accessed
|
||||||
m.Group("/migrate", func() {
|
m.Group("/migrate", func() {
|
||||||
m.Post("/retry", repo.MigrateRetryPost)
|
m.Post("/retry", repo.MigrateRetryPost)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/perm"
|
"gitea.dev/models/perm"
|
||||||
access_model "gitea.dev/models/perm/access"
|
access_model "gitea.dev/models/perm/access"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
@@ -20,11 +22,12 @@ import (
|
|||||||
"gitea.dev/modules/reqctx"
|
"gitea.dev/modules/reqctx"
|
||||||
api "gitea.dev/modules/structs"
|
api "gitea.dev/modules/structs"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/services/context"
|
"gitea.dev/services/audit"
|
||||||
|
gitea_context "gitea.dev/services/context"
|
||||||
"gitea.dev/services/convert"
|
"gitea.dev/services/convert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnable bool) error {
|
func EnableOrDisableWorkflow(ctx *gitea_context.APIContext, workflowID string, isEnable bool) error {
|
||||||
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
|
workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -39,7 +42,21 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
|
|||||||
cfg.DisableWorkflow(workflow.ID)
|
cfg.DisableWorkflow(workflow.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo_model.UpdateRepoUnitConfig(ctx, cfgUnit)
|
if err := repo_model.UpdateRepoUnitConfig(ctx, cfgUnit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordWorkflowToggle(ctx, ctx.Repo.Repository, workflow.ID, isEnable)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWorkflowToggle writes the enable/disable audit event for a workflow.
|
||||||
|
func RecordWorkflowToggle(ctx context.Context, repo *repo_model.Repository, workflowID string, isEnable bool) {
|
||||||
|
action := audit_model.ActionsWorkflowDisable
|
||||||
|
if isEnable {
|
||||||
|
action = audit_model.ActionsWorkflowEnable
|
||||||
|
}
|
||||||
|
audit.Record(ctx, action, repo, "workflow", workflowID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchActionWorkflow manually triggers a workflow_dispatch run.
|
// DispatchActionWorkflow manually triggers a workflow_dispatch run.
|
||||||
@@ -163,6 +180,8 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
|
|||||||
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil {
|
if err := PrepareRunAndInsert(ctx, content, run, inputsWithDefaults); err != nil {
|
||||||
return 0, fmt.Errorf("PrepareRun: %w", err)
|
return 0, fmt.Errorf("PrepareRun: %w", err)
|
||||||
}
|
}
|
||||||
|
audit.RecordAs(ctx, doer, audit_model.ActionsWorkflowDispatch, repo,
|
||||||
|
"workflow", workflowID, "ref", ref, "run_id", run.ID)
|
||||||
return run.ID, nil
|
return run.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
asymkey_model "gitea.dev/models/asymkey"
|
asymkey_model "gitea.dev/models/asymkey"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
deploykey_model "gitea.dev/models/deploykey"
|
deploykey_model "gitea.dev/models/deploykey"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
|
// DeleteRepoDeployKeys deletes all deploy keys of a repository. permissions check should be done outside
|
||||||
@@ -64,6 +66,9 @@ func DeleteDeployKey(ctx context.Context, repo *repo_model.Repository, id int64)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.RepositoryDeployKeyRemove, repo, "deploy_key", deleted.Name)
|
||||||
|
|
||||||
if deleted.KeyType == deploykey_model.KeyTypeToken {
|
if deleted.KeyType == deploykey_model.KeyTypeToken {
|
||||||
return deleted, nil // a token never appears in the authorized_keys file
|
return deleted, nil // a token never appears in the authorized_keys file
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
asymkey_model "gitea.dev/models/asymkey"
|
asymkey_model "gitea.dev/models/asymkey"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeletePublicKey deletes SSH key information both in database and authorized_keys file.
|
// DeletePublicKey deletes SSH key information both in database and authorized_keys file.
|
||||||
@@ -31,9 +33,15 @@ func DeletePublicKey(ctx context.Context, doer *user_model.User, id int64) (err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
owner := audit.ScopeFromUserID(ctx, key.OwnerID)
|
||||||
|
|
||||||
if key.Type == asymkey_model.KeyTypePrincipal {
|
if key.Type == asymkey_model.KeyTypePrincipal {
|
||||||
|
audit.Record(ctx, audit_model.UserKeyPrincipalRemove, owner, "key", key.Name)
|
||||||
|
|
||||||
return RewriteAllPrincipalKeys(ctx)
|
return RewriteAllPrincipalKeys(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.UserKeySSHRemove, owner, "fingerprint", key.Fingerprint)
|
||||||
|
|
||||||
return RewriteAllPublicKeys(ctx)
|
return RewriteAllPublicKeys(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/web/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type doerContextKeyType struct{}
|
||||||
|
|
||||||
|
var doerContextKey doerContextKeyType
|
||||||
|
|
||||||
|
type impersonatorContextKeyType struct{}
|
||||||
|
|
||||||
|
var impersonatorContextKey impersonatorContextKeyType
|
||||||
|
|
||||||
|
// WithImpersonator returns a context that records audit events as performed by
|
||||||
|
// the doer on behalf of the given admin.
|
||||||
|
func WithImpersonator(ctx context.Context, impersonator *user_model.User) context.Context {
|
||||||
|
return context.WithValue(ctx, impersonatorContextKey, impersonator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDoer returns a context that records audit events as the given user.
|
||||||
|
//
|
||||||
|
// Web and API requests need this only in unusual cases: the signed-in user is
|
||||||
|
// already published to the request data store by routers/common.AuthShared and
|
||||||
|
// is picked up automatically. Use it at entry points that have no signed-in
|
||||||
|
// user - the CLI, cron tasks, authentication source syncs and git hooks - before
|
||||||
|
// calling into services that record audit events themselves.
|
||||||
|
func WithDoer(ctx context.Context, doer *user_model.User) context.Context {
|
||||||
|
return context.WithValue(ctx, doerContextKey, doer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doerFromContext resolves the actor of an audit event: an explicit WithDoer
|
||||||
|
// value wins over the signed-in user of the surrounding request. Returns nil
|
||||||
|
// when neither is available, which Record turns into an unknown actor.
|
||||||
|
func doerFromContext(ctx context.Context) *user_model.User {
|
||||||
|
if doer, ok := ctx.Value(doerContextKey).(*user_model.User); ok && doer != nil {
|
||||||
|
return doer
|
||||||
|
}
|
||||||
|
if data := middleware.GetContextData(ctx); data != nil {
|
||||||
|
if doer, ok := data[middleware.ContextDataKeySignedUser].(*user_model.User); ok {
|
||||||
|
return doer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// credentialFromContext returns the credential the surrounding request
|
||||||
|
// authenticated with. It is dropped when the event is recorded for someone
|
||||||
|
// other than the signed-in user, so an explicit actor is never tied to a
|
||||||
|
// credential that is not theirs.
|
||||||
|
func credentialFromContext(ctx context.Context, doer *user_model.User) string {
|
||||||
|
data := middleware.GetContextData(ctx)
|
||||||
|
if data == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
signedUser, _ := data[middleware.ContextDataKeySignedUser].(*user_model.User)
|
||||||
|
if signedUser == nil || signedUser.ID != doer.ID {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
credential, _ := data[middleware.ContextDataKeyAuthCredential].(string)
|
||||||
|
return credential
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImpersonatorFromContext resolves the admin acting as the doer, so an event
|
||||||
|
// recorded during an impersonated session cannot be pinned on the impersonated
|
||||||
|
// user alone. Returns nil for ordinary sessions.
|
||||||
|
func ImpersonatorFromContext(ctx context.Context) *user_model.User {
|
||||||
|
if impersonator, ok := ctx.Value(impersonatorContextKey).(*user_model.User); ok && impersonator != nil {
|
||||||
|
return impersonator
|
||||||
|
}
|
||||||
|
if data := middleware.GetContextData(ctx); data != nil {
|
||||||
|
if impersonator, ok := data[middleware.ContextDataKeyImpersonator].(*user_model.User); ok {
|
||||||
|
return impersonator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
repository_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
"gitea.dev/modules/web/middleware"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkRecordDisabled(b *testing.B) {
|
||||||
|
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDisabled)()
|
||||||
|
ctx := context.Background()
|
||||||
|
u := &user_model.User{ID: 1, Name: "user"}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
RecordAs(ctx, u, audit_model.UserPassword, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkBuildEvent(b *testing.B) {
|
||||||
|
params := RecordParams{
|
||||||
|
Action: audit_model.RepositoryMirrorPushAdd,
|
||||||
|
Actor: audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "actor"},
|
||||||
|
Scope: audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"},
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"remote_address": "https://example.com/repo.git",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = buildEvent(context.Background(), params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRequestContext mimics what routers/common.AuthShared publishes for a
|
||||||
|
// signed-in request.
|
||||||
|
func newRequestContext(t *testing.T, signedIn *user_model.User) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
rc := reqctx.NewRequestContextForTest(t)
|
||||||
|
rc.GetData()[middleware.ContextDataKeySignedUser] = signedIn
|
||||||
|
return rc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEvent(t *testing.T) {
|
||||||
|
doer := &user_model.User{ID: 2, Name: "Doer"}
|
||||||
|
u := &user_model.User{ID: 1, Name: "TestUser"}
|
||||||
|
|
||||||
|
t.Run("MessageFromTemplate", func(t *testing.T) {
|
||||||
|
e := buildEvent(context.Background(), RecordParams{
|
||||||
|
Action: audit_model.UserCreate,
|
||||||
|
Actor: actorRef(doer),
|
||||||
|
Scope: ScopeFromUser(u),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, audit_model.UserCreate, e.Action)
|
||||||
|
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 2, Name: "Doer"}, e.Actor())
|
||||||
|
assert.Equal(t, audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}, e.Scope())
|
||||||
|
assert.Equal(t, "Created user TestUser.", e.Message)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MetadataFillsPlaceholders", func(t *testing.T) {
|
||||||
|
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
|
||||||
|
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
|
||||||
|
|
||||||
|
e := buildEvent(context.Background(), RecordParams{
|
||||||
|
Action: audit_model.RepositoryMirrorPushAdd,
|
||||||
|
Actor: actorRef(doer),
|
||||||
|
Scope: ScopeFromRepository(r),
|
||||||
|
Metadata: metaPairs(
|
||||||
|
"mirror_id", m.ID,
|
||||||
|
"remote_address", m.RemoteAddress,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "TestUser/TestRepo", e.ScopeName)
|
||||||
|
assert.Equal(t, "Added push mirror to git@example.com:repo.git for repository TestUser/TestRepo.", e.Message)
|
||||||
|
assert.InDelta(t, float64(m.ID), audit_model.DecodeMetadata(e.Metadata)["mirror_id"], 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("StatusChangesIncludeTheirNewValue", func(t *testing.T) {
|
||||||
|
e := buildEvent(context.Background(), RecordParams{
|
||||||
|
Action: audit_model.UserRestricted,
|
||||||
|
Actor: actorRef(doer),
|
||||||
|
Scope: ScopeFromUser(u),
|
||||||
|
Metadata: metaPairs("restricted", true),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "Changed restricted status of user TestUser to true.", e.Message)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SystemActorNamesTaskOrKey", func(t *testing.T) {
|
||||||
|
actions := user_model.NewActionsUserWithTaskID(42)
|
||||||
|
e := buildEvent(context.Background(), RecordParams{
|
||||||
|
Action: audit_model.UserCreate,
|
||||||
|
Actor: actorRef(actions),
|
||||||
|
ActorCredential: actorCredential(context.Background(), actions),
|
||||||
|
Scope: ScopeFromUser(u),
|
||||||
|
})
|
||||||
|
assert.Equal(t, user_model.ActionsUserID, e.ActorID)
|
||||||
|
assert.Equal(t, "gitea-actions:42", e.ActorCredential)
|
||||||
|
|
||||||
|
key := user_model.NewDeployKeyUserWithKeyID(7)
|
||||||
|
assert.Equal(t, "deploy-key:7", actorCredential(context.Background(), key))
|
||||||
|
assert.Empty(t, actorCredential(context.Background(), doer))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CredentialFromRequest", func(t *testing.T) {
|
||||||
|
ctx := newRequestContext(t, doer)
|
||||||
|
middleware.GetContextData(ctx)[middleware.ContextDataKeyAuthCredential] = "access-token:9"
|
||||||
|
assert.Equal(t, "access-token:9", actorCredential(ctx, doer))
|
||||||
|
|
||||||
|
// an event recorded for someone other than the signed-in user is not
|
||||||
|
// tied to the credential of that request
|
||||||
|
assert.Empty(t, actorCredential(ctx, u))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RequestInfo", func(t *testing.T) {
|
||||||
|
params := RecordParams{Action: audit_model.UserCreate, Actor: actorRef(doer), Scope: ScopeFromUser(u)}
|
||||||
|
|
||||||
|
e := buildEvent(context.Background(), params)
|
||||||
|
assert.Empty(t, e.IPAddress)
|
||||||
|
assert.Equal(t, audit_model.OriginSystem, e.Origin)
|
||||||
|
|
||||||
|
cliCtx := WithOrigin(context.Background(), audit_model.OriginCLI)
|
||||||
|
assert.Equal(t, audit_model.OriginCLI, buildEvent(cliCtx, params).Origin)
|
||||||
|
|
||||||
|
apiCtx := reqctx.NewRequestContextForTest(t)
|
||||||
|
SetRequestInfo(apiCtx, audit_model.OriginAPI, "127.0.0.1")
|
||||||
|
e = buildEvent(apiCtx, params)
|
||||||
|
assert.Equal(t, "127.0.0.1", e.IPAddress)
|
||||||
|
assert.Equal(t, audit_model.OriginAPI, e.Origin)
|
||||||
|
|
||||||
|
// an explicit origin wins over the one of the surrounding request
|
||||||
|
systemAPIContext := WithOrigin(apiCtx, audit_model.OriginSystem)
|
||||||
|
assert.Equal(t, audit_model.OriginSystem, buildEvent(systemAPIContext, params).Origin)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityRefDisplay(t *testing.T) {
|
||||||
|
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "TestUser"}
|
||||||
|
assert.Equal(t, "TestUser", ref.DisplayName())
|
||||||
|
assert.Equal(t, "/TestUser", ref.HomeLink())
|
||||||
|
assert.True(t, ref.HasLink())
|
||||||
|
|
||||||
|
sys := ScopeSystem()
|
||||||
|
assert.Equal(t, "System", sys.DisplayName())
|
||||||
|
assert.Empty(t, sys.HomeLink())
|
||||||
|
assert.False(t, sys.HasLink())
|
||||||
|
|
||||||
|
// a scope whose entity was deleted keeps its ID but has no name to link to
|
||||||
|
deleted := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3}
|
||||||
|
assert.Empty(t, deleted.DisplayName())
|
||||||
|
assert.False(t, deleted.HasLink())
|
||||||
|
|
||||||
|
repo := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 3, Name: "Test User/Test Repo"}
|
||||||
|
assert.Equal(t, "/Test%20User/Test%20Repo", repo.HomeLink())
|
||||||
|
assert.True(t, repo.HasLink())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeDecodeMetadata(t *testing.T) {
|
||||||
|
raw := audit_model.EncodeMetadata(metaPairs("repo_id", int64(42), "repo", "o/r"))
|
||||||
|
decoded := audit_model.DecodeMetadata(raw)
|
||||||
|
assert.InDelta(t, 42.0, decoded["repo_id"], 0) // json numbers decode as float64
|
||||||
|
assert.Equal(t, "o/r", decoded["repo"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoerFromContext(t *testing.T) {
|
||||||
|
doer := &user_model.User{ID: 2, Name: "Doer"}
|
||||||
|
signedIn := &user_model.User{ID: 3, Name: "SignedIn"}
|
||||||
|
|
||||||
|
t.Run("NoActor", func(t *testing.T) {
|
||||||
|
assert.Nil(t, doerFromContext(context.Background()))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithDoer", func(t *testing.T) {
|
||||||
|
assert.Equal(t, doer, doerFromContext(WithDoer(context.Background(), doer)))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SignedInUserOfRequest", func(t *testing.T) {
|
||||||
|
ctx := newRequestContext(t, signedIn)
|
||||||
|
assert.Equal(t, signedIn, doerFromContext(ctx))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WithDoerWinsOverSignedInUser", func(t *testing.T) {
|
||||||
|
ctx := WithDoer(newRequestContext(t, signedIn), doer)
|
||||||
|
assert.Equal(t, doer, doerFromContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// An impersonated session must never pin an event on the impersonated user
|
||||||
|
// alone, otherwise an admin could act in someone else's name untraceably.
|
||||||
|
func TestImpersonatorRef(t *testing.T) {
|
||||||
|
admin := &user_model.User{ID: 1, Name: "Admin"}
|
||||||
|
impersonated := &user_model.User{ID: 2, Name: "Impersonated"}
|
||||||
|
|
||||||
|
rc := reqctx.NewRequestContextForTest(t)
|
||||||
|
rc.GetData()[middleware.ContextDataKeySignedUser] = impersonated
|
||||||
|
rc.GetData()[middleware.ContextDataKeyImpersonator] = admin
|
||||||
|
|
||||||
|
assert.Equal(t, admin, ImpersonatorFromContext(rc))
|
||||||
|
|
||||||
|
e := buildEvent(rc, RecordParams{
|
||||||
|
Action: audit_model.UserPassword,
|
||||||
|
Actor: actorRef(doerFromContext(rc)),
|
||||||
|
Impersonator: impersonatorRef(ImpersonatorFromContext(rc), doerFromContext(rc)),
|
||||||
|
Scope: ScopeFromUser(impersonated),
|
||||||
|
})
|
||||||
|
assert.Equal(t, int64(2), e.ActorID)
|
||||||
|
assert.Equal(t, &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Admin"}, e.Impersonator())
|
||||||
|
|
||||||
|
// an admin acting as themselves is not an impersonation
|
||||||
|
assert.Nil(t, impersonatorRef(admin, admin))
|
||||||
|
assert.Nil(t, impersonatorRef(nil, impersonated))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unresolvable actor must still produce an event, so a missing entry point
|
||||||
|
// never silently drops security relevant records.
|
||||||
|
func TestActorRefWithoutDoer(t *testing.T) {
|
||||||
|
ref := actorRef(nil)
|
||||||
|
assert.Equal(t, "Unknown", ref.DisplayName())
|
||||||
|
assert.False(t, ref.HasLink())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMessage(t *testing.T) {
|
||||||
|
actor := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: 1, Name: "Actor"}
|
||||||
|
scope := audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: 2, Name: "owner/repo"}
|
||||||
|
|
||||||
|
t.Run("EveryActionHasATemplate", func(t *testing.T) {
|
||||||
|
for _, action := range audit_model.AllActions() {
|
||||||
|
tmpl, ok := audit_model.MessageTemplate(action)
|
||||||
|
assert.True(t, ok, "action %q has no message template", action)
|
||||||
|
assert.NotEmpty(t, tmpl, "action %q has empty message template", action)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReservedPlaceholders", func(t *testing.T) {
|
||||||
|
assert.Equal(t,
|
||||||
|
"User Actor started impersonating user owner/repo.",
|
||||||
|
renderMessage(audit_model.UserImpersonation, actor, scope, nil),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NonStringMetadata", func(t *testing.T) {
|
||||||
|
assert.Equal(t,
|
||||||
|
"Removed external login from authentication source 7 for user owner/repo.",
|
||||||
|
renderMessage(audit_model.UserExternalLoginRemove, actor, scope, map[string]any{"auth_source_id": int64(7)}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingMetadataKeepsTheKey", func(t *testing.T) {
|
||||||
|
assert.Equal(t,
|
||||||
|
"Added deploy key deploy_key for repository owner/repo.",
|
||||||
|
renderMessage(audit_model.RepositoryDeployKeyAdd, actor, scope, nil),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UnknownActionFallsBackToItsName", func(t *testing.T) {
|
||||||
|
assert.Equal(t, "not:an:action", renderMessage(audit_model.Action("not:an:action"), actor, scope, nil))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionFilters(t *testing.T) {
|
||||||
|
assert.True(t, audit_model.IsActionFilter("user:impersonation"))
|
||||||
|
assert.True(t, audit_model.IsActionFilter(audit_model.UserImpersonation))
|
||||||
|
assert.False(t, audit_model.IsActionFilter("user:unknown"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecordParams describes an audit event. Callers (or domain-specific helpers)
|
||||||
|
// supply metadata; the message is rendered from the action's template.
|
||||||
|
type RecordParams struct {
|
||||||
|
Action audit_model.Action
|
||||||
|
Actor audit_model.EntityRef
|
||||||
|
ActorCredential string
|
||||||
|
Impersonator *audit_model.EntityRef
|
||||||
|
Scope audit_model.EntityRef
|
||||||
|
Metadata map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type originContextKeyType struct{}
|
||||||
|
|
||||||
|
var originContextKey originContextKeyType
|
||||||
|
|
||||||
|
type requestInfoContextKeyType struct{}
|
||||||
|
|
||||||
|
var requestInfoContextKey requestInfoContextKeyType
|
||||||
|
|
||||||
|
// requestInfo is what an audit event needs to know about the request it was
|
||||||
|
// recorded for. The routers publish it, so services don't have to reach for the
|
||||||
|
// request themselves.
|
||||||
|
type requestInfo struct {
|
||||||
|
origin audit_model.Origin
|
||||||
|
ipAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOrigin returns a context that records audit events with the given origin.
|
||||||
|
// It is for entry points that serve no request, eg: the CLI or a cron task.
|
||||||
|
func WithOrigin(ctx context.Context, origin audit_model.Origin) context.Context {
|
||||||
|
return context.WithValue(ctx, originContextKey, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRequestInfo attributes the audit events recorded while serving a request
|
||||||
|
// to the given origin and client address.
|
||||||
|
func SetRequestInfo(store reqctx.RequestDataStore, origin audit_model.Origin, ipAddress string) {
|
||||||
|
store.SetContextValue(requestInfoContextKey, &requestInfo{origin: origin, ipAddress: ipAddress})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestInfoFromContext(ctx context.Context) *requestInfo {
|
||||||
|
info, _ := ctx.Value(requestInfoContextKey).(*requestInfo)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEvent(ctx context.Context, params RecordParams) *audit_model.Event {
|
||||||
|
e := &audit_model.Event{
|
||||||
|
Action: params.Action,
|
||||||
|
ActorID: params.Actor.ID,
|
||||||
|
ActorName: params.Actor.DisplayName(),
|
||||||
|
ActorCredential: params.ActorCredential,
|
||||||
|
ScopeType: params.Scope.Type,
|
||||||
|
ScopeID: params.Scope.ID,
|
||||||
|
ScopeName: params.Scope.DisplayName(),
|
||||||
|
Message: renderMessage(params.Action, params.Actor, params.Scope, params.Metadata),
|
||||||
|
Metadata: audit_model.EncodeMetadata(params.Metadata),
|
||||||
|
IPAddress: getIPAddress(ctx),
|
||||||
|
Origin: getOrigin(ctx),
|
||||||
|
TimestampUnix: timeutil.TimeStamp(time.Now().Unix()),
|
||||||
|
}
|
||||||
|
if params.Impersonator != nil {
|
||||||
|
e.ImpersonatorID = params.Impersonator.ID
|
||||||
|
e.ImpersonatorName = params.Impersonator.DisplayName()
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIPAddress(ctx context.Context) string {
|
||||||
|
if info := requestInfoFromContext(ctx); info != nil {
|
||||||
|
return info.ipAddress
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrigin(ctx context.Context) audit_model.Origin {
|
||||||
|
if origin, ok := ctx.Value(originContextKey).(audit_model.Origin); ok && origin != "" {
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
if info := requestInfoFromContext(ctx); info != nil && info.origin != "" {
|
||||||
|
return info.origin
|
||||||
|
}
|
||||||
|
return audit_model.OriginSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record writes an audit event for an action against a scope entity. The actor
|
||||||
|
// is the signed-in user of the surrounding request, or whoever audit.WithDoer
|
||||||
|
// named for a background context.
|
||||||
|
//
|
||||||
|
// The scope is the affected entity and may be a *user.User,
|
||||||
|
// *organization.Organization, *repo.Repository, an EntityRef, or nil for an
|
||||||
|
// instance-wide/system event. Metadata is supplied as alternating
|
||||||
|
// string-key/value pairs and fills the placeholders of the action's message
|
||||||
|
// template, so every key a template names must be passed here.
|
||||||
|
//
|
||||||
|
// audit.Record(ctx, audit_model.RepositoryArchive, repo)
|
||||||
|
// audit.Record(ctx, audit_model.RepositoryDeployKeyAdd, repo, "deploy_key", key.Name)
|
||||||
|
func Record(ctx context.Context, action audit_model.Action, scope any, metadata ...any) {
|
||||||
|
RecordAs(ctx, doerFromContext(ctx), action, scope, metadata...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordAs is Record with an explicit actor, for the few call sites where the
|
||||||
|
// acting user is not the one the context resolves to.
|
||||||
|
func RecordAs(ctx context.Context, doer *user_model.User, action audit_model.Action, scope any, metadata ...any) {
|
||||||
|
writeEvent(ctx, RecordParams{
|
||||||
|
Action: action,
|
||||||
|
Actor: actorRef(doer),
|
||||||
|
ActorCredential: actorCredential(ctx, doer),
|
||||||
|
Impersonator: impersonatorRef(ImpersonatorFromContext(ctx), doer),
|
||||||
|
Scope: scopeRef(scope),
|
||||||
|
Metadata: metaPairs(metadata...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEvent persists an audit event when audit logging is enabled.
|
||||||
|
func writeEvent(ctx context.Context, params RecordParams) {
|
||||||
|
if !setting.AuditRecordEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e := buildEvent(ctx, params)
|
||||||
|
|
||||||
|
if err := audit_model.InsertEvent(ctx, e); err != nil {
|
||||||
|
log.Error("Error writing audit event action=%s actor=%s scope=%s/%d to database: %v", e.Action, e.ActorName, e.ScopeType, e.ScopeID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindEvents(ctx context.Context, opts *audit_model.EventSearchOptions) ([]*audit_model.Event, int64, error) {
|
||||||
|
return audit_model.FindEvents(ctx, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaPairs builds caller-defined metadata from alternating string-key/value
|
||||||
|
// pairs. Keys should be stable for log parsers. A non-string key is skipped and
|
||||||
|
// logged rather than panicking: audit recording must never crash the request
|
||||||
|
// that triggered it.
|
||||||
|
func metaPairs(pairs ...any) map[string]any {
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]any, len(pairs)/2)
|
||||||
|
for i := 0; i+1 < len(pairs); i += 2 {
|
||||||
|
key, ok := pairs[i].(string)
|
||||||
|
if !ok {
|
||||||
|
log.Error("audit: metadata key must be string, got %T; skipping pair", pairs[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[key] = pairs[i+1]
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
"gitea.dev/modules/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteEventsAsJSON writes one JSON object per line.
|
||||||
|
func WriteEventsAsJSON(w io.Writer, events []*audit_model.Event) error {
|
||||||
|
for _, event := range events {
|
||||||
|
b, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(append(b, '\n')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
repository_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteEventsAsJSON(t *testing.T) {
|
||||||
|
r := &repository_model.Repository{ID: 3, Name: "TestRepo", OwnerName: "TestUser"}
|
||||||
|
m := &repository_model.PushMirror{ID: 4, RemoteAddress: "git@example.com:repo.git"}
|
||||||
|
doer := &user_model.User{ID: 2, Name: "Doer"}
|
||||||
|
|
||||||
|
ctx := reqctx.NewRequestContextForTest(t)
|
||||||
|
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
|
||||||
|
|
||||||
|
e := buildEvent(ctx, RecordParams{
|
||||||
|
Action: audit_model.RepositoryMirrorPushAdd,
|
||||||
|
Actor: actorRef(doer),
|
||||||
|
Scope: ScopeFromRepository(r),
|
||||||
|
Metadata: metaPairs(
|
||||||
|
"mirror_id", m.ID,
|
||||||
|
"remote_address", m.RemoteAddress,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
e.TimestampUnix = timeutil.TimeStamp(time.Time{}.Unix())
|
||||||
|
|
||||||
|
sb := strings.Builder{}
|
||||||
|
assert.NoError(t, WriteEventsAsJSON(&sb, []*audit_model.Event{e, e}))
|
||||||
|
out := sb.String()
|
||||||
|
assert.Equal(t, 2, strings.Count(out, "\n"))
|
||||||
|
assert.Contains(t, out, `"action":"repository:mirror:push:add"`)
|
||||||
|
assert.Contains(t, out, `"name":"Doer"`)
|
||||||
|
assert.Contains(t, out, `"metadata"`)
|
||||||
|
assert.Contains(t, out, `"remote_address":"git@example.com:repo.git"`)
|
||||||
|
assert.Contains(t, out, `"ip_address":"127.0.0.1"`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
unittest.MainTest(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reserved placeholders, filled from the event itself rather than from metadata.
|
||||||
|
const (
|
||||||
|
placeholderScope = "scope"
|
||||||
|
placeholderActor = "actor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderMessage fills the action's template from the event's scope, actor and
|
||||||
|
// metadata. A missing template or an unresolved placeholder is logged and
|
||||||
|
// rendered as the bare key: audit recording must never fail the request that
|
||||||
|
// triggered it, and a partial message is more useful than none.
|
||||||
|
func renderMessage(action audit_model.Action, actor, scope audit_model.EntityRef, metadata map[string]any) string {
|
||||||
|
tmpl, ok := audit_model.MessageTemplate(action)
|
||||||
|
if !ok {
|
||||||
|
log.Error("audit: no message template for action %q", action)
|
||||||
|
return string(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
rest := tmpl
|
||||||
|
for {
|
||||||
|
start := strings.IndexByte(rest, '{')
|
||||||
|
if start < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.IndexByte(rest[start:], '}')
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end += start
|
||||||
|
|
||||||
|
key := rest[start+1 : end]
|
||||||
|
sb.WriteString(rest[:start])
|
||||||
|
sb.WriteString(resolvePlaceholder(action, key, actor, scope, metadata))
|
||||||
|
rest = rest[end+1:]
|
||||||
|
}
|
||||||
|
sb.WriteString(rest)
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePlaceholder(action audit_model.Action, key string, actor, scope audit_model.EntityRef, metadata map[string]any) string {
|
||||||
|
switch key {
|
||||||
|
case placeholderScope:
|
||||||
|
return scope.DisplayName()
|
||||||
|
case placeholderActor:
|
||||||
|
return actor.DisplayName()
|
||||||
|
}
|
||||||
|
if v, ok := metadata[key]; ok {
|
||||||
|
return fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
log.Error("audit: action %q has no metadata for placeholder %q", action, key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
issues_model "gitea.dev/models/issues"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
notify_service "gitea.dev/services/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
notify_service.RegisterNotifier(new(auditNotifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditNotifier struct {
|
||||||
|
notify_service.NullNotifier
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ notify_service.Notifier = new(auditNotifier)
|
||||||
|
|
||||||
|
func (n *auditNotifier) CreateRepository(ctx context.Context, doer, _ *user_model.User, repo *repo_model.Repository) {
|
||||||
|
RecordAs(ctx, doer, audit_model.RepositoryCreate, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
|
||||||
|
RecordAs(ctx, doer, audit_model.RepositoryCreateFork, repo, "base_repo", oldRepo.FullName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
|
||||||
|
RecordAs(ctx, doer, audit_model.RepositoryName, repo, "previous_name", oldRepoName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
|
||||||
|
RecordAs(ctx, doer, audit_model.RepositoryTransferFinish, repo, "old_owner", oldOwnerName, "new_owner", repo.OwnerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) RepoPendingTransfer(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) {
|
||||||
|
RecordAs(ctx, doer, audit_model.RepositoryTransferStart, repo, "new_owner", newOwner.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) ChangeDefaultBranch(ctx context.Context, repo *repo_model.Repository) {
|
||||||
|
Record(ctx, audit_model.RepositoryBranchDefault, repo, "default_branch", repo.DefaultBranch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueLabel(issue *issues_model.Issue) string {
|
||||||
|
return fmt.Sprintf("#%d", issue.Index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadIssueRepo(ctx context.Context, issue *issues_model.Issue) *repo_model.Repository {
|
||||||
|
if issue.Repo == nil {
|
||||||
|
if err := issue.LoadRepo(ctx); err != nil {
|
||||||
|
log.Error("audit: LoadRepo for issue %d: %v", issue.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issue.Repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueOrPR(issue *issues_model.Issue, issueAction, prAction audit_model.Action) (audit_model.Action, string) {
|
||||||
|
if issue.IsPull {
|
||||||
|
return prAction, "pull_request"
|
||||||
|
}
|
||||||
|
return issueAction, "issue"
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueMeta keeps the label, ID and title keys of every issue and pull request
|
||||||
|
// event consistent; the ID is always the issue row ID the label refers to.
|
||||||
|
func issueMeta(issue *issues_model.Issue, key string) []any {
|
||||||
|
return []any{key, issueLabel(issue), key + "_id", issue.ID, "title", issue.Title}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, _ []*user_model.User) {
|
||||||
|
repo := loadIssueRepo(ctx, issue)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RecordAs(ctx, issue.Poster, audit_model.IssueCreate, repo, issueMeta(issue, "issue")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) {
|
||||||
|
repo := loadIssueRepo(ctx, issue)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action, key := issueOrPR(issue, audit_model.IssueDelete, audit_model.PullRequestDelete)
|
||||||
|
RecordAs(ctx, doer, action, repo, issueMeta(issue, key)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, _ []*user_model.User) {
|
||||||
|
if pr.Issue == nil {
|
||||||
|
if err := pr.LoadIssue(ctx); err != nil {
|
||||||
|
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := loadIssueRepo(ctx, pr.Issue)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RecordAs(ctx, pr.Issue.Poster, audit_model.PullRequestCreate, repo, issueMeta(pr.Issue, "pull_request")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
|
||||||
|
if pr.Issue == nil {
|
||||||
|
if err := pr.LoadIssue(ctx); err != nil {
|
||||||
|
log.Error("audit: LoadIssue for pull request %d: %v", pr.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := loadIssueRepo(ctx, pr.Issue)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RecordAs(ctx, doer, audit_model.PullRequestMerge, repo, issueMeta(pr.Issue, "pull_request")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, _ []*user_model.User) {
|
||||||
|
action, key := issueOrPR(issue, audit_model.IssueCommentCreate, audit_model.PullRequestCommentCreate)
|
||||||
|
RecordAs(ctx, doer, action, repo, key, issueLabel(issue), "comment_id", comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) DeleteComment(ctx context.Context, doer *user_model.User, comment *issues_model.Comment) {
|
||||||
|
if comment.Issue == nil {
|
||||||
|
if err := comment.LoadIssue(ctx); err != nil {
|
||||||
|
log.Error("audit: LoadIssue for comment %d: %v", comment.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := loadIssueRepo(ctx, comment.Issue)
|
||||||
|
if repo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action, key := issueOrPR(comment.Issue, audit_model.IssueCommentDelete, audit_model.PullRequestCommentDelete)
|
||||||
|
RecordAs(ctx, doer, action, repo, key, issueLabel(comment.Issue), "comment_id", comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) NewWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
|
||||||
|
RecordAs(ctx, doer, audit_model.WikiPageCreate, repo, "page", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) EditWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page, _ string) {
|
||||||
|
RecordAs(ctx, doer, audit_model.WikiPageUpdate, repo, "page", page)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *auditNotifier) DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, page string) {
|
||||||
|
RecordAs(ctx, doer, audit_model.WikiPageDelete, repo, "page", page)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
issues_model "gitea.dev/models/issues"
|
||||||
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unittest"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/reqctx"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuditNotifier(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
|
||||||
|
|
||||||
|
doer := &user_model.User{ID: 2, Name: "doer"}
|
||||||
|
repo := &repo_model.Repository{ID: 1, OwnerName: "owner", Name: "repo"}
|
||||||
|
ctx := reqctx.NewRequestContextForTest(t)
|
||||||
|
SetRequestInfo(ctx, audit_model.OriginUI, "127.0.0.1")
|
||||||
|
notifier := new(auditNotifier)
|
||||||
|
|
||||||
|
issue := &issues_model.Issue{ID: 10, Index: 5, Title: "Issue title", Poster: doer, Repo: repo}
|
||||||
|
notifier.NewIssue(ctx, issue, nil)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
|
||||||
|
Action: audit_model.IssueCreate,
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: repo.ID,
|
||||||
|
Origin: audit_model.OriginUI,
|
||||||
|
})
|
||||||
|
|
||||||
|
pr := &issues_model.PullRequest{ID: 11, Issue: &issues_model.Issue{ID: 12, Index: 6, Title: "PR title", Poster: doer, Repo: repo, IsPull: true}}
|
||||||
|
notifier.NewPullRequest(ctx, pr, nil)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
|
||||||
|
Action: audit_model.PullRequestCreate,
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: repo.ID,
|
||||||
|
Origin: audit_model.OriginUI,
|
||||||
|
})
|
||||||
|
|
||||||
|
notifier.NewWikiPage(ctx, doer, repo, "Home", "")
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
|
||||||
|
Action: audit_model.WikiPageCreate,
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: repo.ID,
|
||||||
|
Origin: audit_model.OriginUI,
|
||||||
|
})
|
||||||
|
|
||||||
|
notifier.CreateRepository(ctx, doer, doer, repo)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
|
||||||
|
Action: audit_model.RepositoryCreate,
|
||||||
|
ActorID: doer.ID,
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: repo.ID,
|
||||||
|
Origin: audit_model.OriginUI,
|
||||||
|
})
|
||||||
|
|
||||||
|
notifier.TransferRepository(ctx, doer, repo, "previous_owner")
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
|
||||||
|
Action: audit_model.RepositoryTransferFinish,
|
||||||
|
ActorID: doer.ID,
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: repo.ID,
|
||||||
|
Message: "Transferred repository owner/repo from previous_owner to owner.",
|
||||||
|
})
|
||||||
|
|
||||||
|
notifier.ChangeDefaultBranch(ctx, repo)
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{
|
||||||
|
Action: audit_model.RepositoryBranchDefault,
|
||||||
|
ScopeType: audit_model.ScopeRepository,
|
||||||
|
ScopeID: repo.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
repository_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
// actorRef builds the actor reference of an event. An unresolvable actor means
|
||||||
|
// an entry point neither runs inside an authenticated request nor called
|
||||||
|
// WithDoer; record the event with an "Unknown" actor rather than dropping it,
|
||||||
|
// and log so the missing entry point is visible.
|
||||||
|
func actorRef(doer *user_model.User) audit_model.EntityRef {
|
||||||
|
if doer == nil {
|
||||||
|
log.Error("audit: no actor in context, recording event as unknown actor")
|
||||||
|
return audit_model.EntityRef{Type: audit_model.ScopeUser, Name: "Unknown"}
|
||||||
|
}
|
||||||
|
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: doer.ID, Name: doer.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// actorCredential names what the actor acted with: the task or key behind a
|
||||||
|
// system user, otherwise the token the surrounding request authenticated with.
|
||||||
|
// An incident then traces one credential across every event it produced,
|
||||||
|
// instead of stopping at the account that owns it.
|
||||||
|
func actorCredential(ctx context.Context, doer *user_model.User) string {
|
||||||
|
if doer == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if doer.ExtDoerData != nil {
|
||||||
|
return doer.ExtDoerData.EncodeToString()
|
||||||
|
}
|
||||||
|
return credentialFromContext(ctx, doer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// impersonatorRef names the admin behind an impersonated session. It is dropped
|
||||||
|
// when the actor is the admin themselves, so events an admin performs before
|
||||||
|
// entering or after leaving an impersonation are not marked as impersonated.
|
||||||
|
func impersonatorRef(impersonator, doer *user_model.User) *audit_model.EntityRef {
|
||||||
|
if impersonator == nil || (doer != nil && impersonator.ID == doer.ID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &audit_model.EntityRef{Type: audit_model.ScopeUser, ID: impersonator.ID, Name: impersonator.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeFromUser(u *user_model.User) audit_model.EntityRef {
|
||||||
|
if u == nil {
|
||||||
|
return audit_model.EntityRef{}
|
||||||
|
}
|
||||||
|
if u.IsOrganization() {
|
||||||
|
return audit_model.EntityRef{Type: audit_model.ScopeOrganization, ID: u.ID, Name: u.Name}
|
||||||
|
}
|
||||||
|
return audit_model.EntityRef{Type: audit_model.ScopeUser, ID: u.ID, Name: u.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopeFromUserID resolves the scope of a user known only by ID, for call sites
|
||||||
|
// that would otherwise load the user solely to name it. It costs nothing while
|
||||||
|
// audit logging is off, and a failed lookup still yields a usable scope so the
|
||||||
|
// event is never dropped.
|
||||||
|
func ScopeFromUserID(ctx context.Context, id int64) audit_model.EntityRef {
|
||||||
|
ref := audit_model.EntityRef{Type: audit_model.ScopeUser, ID: id}
|
||||||
|
if !setting.AuditRecordEnabled() {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
u, err := user_model.GetUserByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("audit: GetUserByID(%d): %v", id, err)
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
return ScopeFromUser(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeFromRepository(repo *repository_model.Repository) audit_model.EntityRef {
|
||||||
|
if repo == nil {
|
||||||
|
return audit_model.EntityRef{}
|
||||||
|
}
|
||||||
|
return audit_model.EntityRef{Type: audit_model.ScopeRepository, ID: repo.ID, Name: repo.FullName()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScopeSystem() audit_model.EntityRef {
|
||||||
|
return audit_model.EntityRef{Type: audit_model.ScopeSystem}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeRef derives an EntityRef from the affected entity passed to Record.
|
||||||
|
// Supported types: *user_model.User, *repository_model.Repository, EntityRef,
|
||||||
|
// or nil for an instance-wide event.
|
||||||
|
func scopeRef(scope any) audit_model.EntityRef {
|
||||||
|
switch s := scope.(type) {
|
||||||
|
case nil:
|
||||||
|
return ScopeSystem()
|
||||||
|
case audit_model.EntityRef:
|
||||||
|
return s
|
||||||
|
case *user_model.User:
|
||||||
|
return ScopeFromUser(s)
|
||||||
|
case *repository_model.Repository:
|
||||||
|
return ScopeFromRepository(s)
|
||||||
|
default:
|
||||||
|
// Audit recording must never crash the request that triggered it; record
|
||||||
|
// a system-scoped event instead of panicking on an unexpected type.
|
||||||
|
log.Error("audit: unsupported scope type %T; recording as system scope", scope)
|
||||||
|
return ScopeSystem()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
repository_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScopedActions holds the action variants for a resource that can be owned by a
|
||||||
|
// repository, organization, user or the instance itself. RecordScoped selects
|
||||||
|
// the matching one based on the owner/repo passed at the call site.
|
||||||
|
type ScopedActions struct {
|
||||||
|
Repo audit_model.Action
|
||||||
|
Org audit_model.Action
|
||||||
|
User audit_model.Action
|
||||||
|
System audit_model.Action
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
SecretAdd = ScopedActions{
|
||||||
|
Repo: audit_model.RepositorySecretAdd,
|
||||||
|
Org: audit_model.OrganizationSecretAdd,
|
||||||
|
User: audit_model.UserSecretAdd,
|
||||||
|
}
|
||||||
|
SecretUpdate = ScopedActions{
|
||||||
|
Repo: audit_model.RepositorySecretUpdate,
|
||||||
|
Org: audit_model.OrganizationSecretUpdate,
|
||||||
|
User: audit_model.UserSecretUpdate,
|
||||||
|
}
|
||||||
|
SecretRemove = ScopedActions{
|
||||||
|
Repo: audit_model.RepositorySecretRemove,
|
||||||
|
Org: audit_model.OrganizationSecretRemove,
|
||||||
|
User: audit_model.UserSecretRemove,
|
||||||
|
}
|
||||||
|
|
||||||
|
OAuth2ApplicationAdd = ScopedActions{
|
||||||
|
User: audit_model.UserOAuth2ApplicationAdd,
|
||||||
|
Org: audit_model.OrganizationOAuth2ApplicationAdd,
|
||||||
|
System: audit_model.SystemOAuth2ApplicationAdd,
|
||||||
|
}
|
||||||
|
OAuth2ApplicationUpdate = ScopedActions{
|
||||||
|
User: audit_model.UserOAuth2ApplicationUpdate,
|
||||||
|
Org: audit_model.OrganizationOAuth2ApplicationUpdate,
|
||||||
|
System: audit_model.SystemOAuth2ApplicationUpdate,
|
||||||
|
}
|
||||||
|
OAuth2ApplicationSecret = ScopedActions{
|
||||||
|
User: audit_model.UserOAuth2ApplicationSecret,
|
||||||
|
Org: audit_model.OrganizationOAuth2ApplicationSecret,
|
||||||
|
System: audit_model.SystemOAuth2ApplicationSecret,
|
||||||
|
}
|
||||||
|
OAuth2ApplicationRemove = ScopedActions{
|
||||||
|
User: audit_model.UserOAuth2ApplicationRemove,
|
||||||
|
Org: audit_model.OrganizationOAuth2ApplicationRemove,
|
||||||
|
System: audit_model.SystemOAuth2ApplicationRemove,
|
||||||
|
}
|
||||||
|
OAuth2ApplicationRevoke = ScopedActions{
|
||||||
|
User: audit_model.UserOAuth2ApplicationRevoke,
|
||||||
|
}
|
||||||
|
|
||||||
|
WebhookAdd = ScopedActions{
|
||||||
|
Repo: audit_model.RepositoryWebhookAdd,
|
||||||
|
Org: audit_model.OrganizationWebhookAdd,
|
||||||
|
User: audit_model.UserWebhookAdd,
|
||||||
|
System: audit_model.SystemWebhookAdd,
|
||||||
|
}
|
||||||
|
WebhookUpdate = ScopedActions{
|
||||||
|
Repo: audit_model.RepositoryWebhookUpdate,
|
||||||
|
Org: audit_model.OrganizationWebhookUpdate,
|
||||||
|
User: audit_model.UserWebhookUpdate,
|
||||||
|
System: audit_model.SystemWebhookUpdate,
|
||||||
|
}
|
||||||
|
WebhookRemove = ScopedActions{
|
||||||
|
Repo: audit_model.RepositoryWebhookRemove,
|
||||||
|
Org: audit_model.OrganizationWebhookRemove,
|
||||||
|
User: audit_model.UserWebhookRemove,
|
||||||
|
System: audit_model.SystemWebhookRemove,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveScope maps an (owner, repo) pair to the scoped action and audit scope.
|
||||||
|
// The rules cover every multi-scope resource (secrets, OAuth2 apps, webhooks):
|
||||||
|
// a repo wins when set, a nil owner means the instance, otherwise the owner's
|
||||||
|
// kind decides.
|
||||||
|
func resolveScope(actions ScopedActions, owner *user_model.User, repo *repository_model.Repository) (audit_model.Action, audit_model.EntityRef) {
|
||||||
|
switch {
|
||||||
|
case repo != nil:
|
||||||
|
return actions.Repo, ScopeFromRepository(repo)
|
||||||
|
case owner == nil:
|
||||||
|
return actions.System, ScopeSystem()
|
||||||
|
case owner.IsOrganization():
|
||||||
|
return actions.Org, ScopeFromUser(owner)
|
||||||
|
default:
|
||||||
|
return actions.User, ScopeFromUser(owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordScoped records an audit event for a resource owned by a repository (repo
|
||||||
|
// set), organization, user, or the instance (owner nil, repo nil). It picks the
|
||||||
|
// scoped action and scope; each variant carries its own message template, so the
|
||||||
|
// wording follows automatically. Metadata is supplied as alternating
|
||||||
|
// string-key/value pairs, like Record.
|
||||||
|
func RecordScoped(ctx context.Context, owner *user_model.User, repo *repository_model.Repository, actions ScopedActions, metadata ...any) {
|
||||||
|
action, scope := resolveScope(actions, owner, repo)
|
||||||
|
if action == "" {
|
||||||
|
log.Error("audit: no action configured for scope type %s", scope.Type)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doer := doerFromContext(ctx)
|
||||||
|
writeEvent(ctx, RecordParams{
|
||||||
|
Action: action,
|
||||||
|
Actor: actorRef(doer),
|
||||||
|
ActorCredential: actorCredential(ctx, doer),
|
||||||
|
Scope: scope,
|
||||||
|
Metadata: metaPairs(metadata...),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
|
repository_model "gitea.dev/models/repo"
|
||||||
|
user_model "gitea.dev/models/user"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveScope(t *testing.T) {
|
||||||
|
actions := WebhookAdd
|
||||||
|
|
||||||
|
org := &user_model.User{ID: 10, Name: "MyOrg", Type: user_model.UserTypeOrganization}
|
||||||
|
usr := &user_model.User{ID: 11, Name: "MyUser", Type: user_model.UserTypeIndividual}
|
||||||
|
repo := &repository_model.Repository{ID: 12, Name: "repo", OwnerName: "MyOrg"}
|
||||||
|
|
||||||
|
t.Run("repo wins over owner", func(t *testing.T) {
|
||||||
|
action, scope := resolveScope(actions, org, repo)
|
||||||
|
assert.Equal(t, audit_model.RepositoryWebhookAdd, action)
|
||||||
|
assert.Equal(t, audit_model.ScopeRepository, scope.Type)
|
||||||
|
assert.Equal(t, "MyOrg/repo", scope.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("organization owner", func(t *testing.T) {
|
||||||
|
action, scope := resolveScope(actions, org, nil)
|
||||||
|
assert.Equal(t, audit_model.OrganizationWebhookAdd, action)
|
||||||
|
assert.Equal(t, audit_model.ScopeOrganization, scope.Type)
|
||||||
|
assert.Equal(t, "MyOrg", scope.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user owner", func(t *testing.T) {
|
||||||
|
action, scope := resolveScope(actions, usr, nil)
|
||||||
|
assert.Equal(t, audit_model.UserWebhookAdd, action)
|
||||||
|
assert.Equal(t, audit_model.ScopeUser, scope.Type)
|
||||||
|
assert.Equal(t, "MyUser", scope.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("system when no owner and no repo", func(t *testing.T) {
|
||||||
|
action, scope := resolveScope(actions, nil, nil)
|
||||||
|
assert.Equal(t, audit_model.SystemWebhookAdd, action)
|
||||||
|
assert.Equal(t, audit_model.ScopeSystem, scope.Type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit recording must never crash the request that triggered it.
|
||||||
|
func TestRecordHelpersNeverPanic(t *testing.T) {
|
||||||
|
t.Run("metaPairs skips non-string keys", func(t *testing.T) {
|
||||||
|
var m map[string]any
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
m = metaPairs("ok", 1, 42 /* bad key */, "value", "second", 2)
|
||||||
|
})
|
||||||
|
assert.Equal(t, 1, m["ok"])
|
||||||
|
assert.Equal(t, 2, m["second"])
|
||||||
|
assert.Len(t, m, 2) // the pair with the non-string key is dropped
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scopeRef falls back to system on unsupported type", func(t *testing.T) {
|
||||||
|
var ref audit_model.EntityRef
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
ref = scopeRef(struct{ Foo string }{Foo: "bar"})
|
||||||
|
})
|
||||||
|
assert.Equal(t, audit_model.ScopeSystem, ref.Type)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
auth_model "gitea.dev/models/auth"
|
auth_model "gitea.dev/models/auth"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/auth/httpauth"
|
"gitea.dev/modules/auth/httpauth"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure the struct implements the interface.
|
// Ensure the struct implements the interface.
|
||||||
@@ -71,7 +73,7 @@ func parseAuthBasic(req *http.Request) (ret struct{ authToken, uname, passwd str
|
|||||||
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
|
// VerifyAuthToken only the access token provided as parameter, used by other auth methods that want to reuse access token verification logic
|
||||||
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
|
func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store DataStore, sess SessionStore, authToken string) (*user_model.User, error) {
|
||||||
// get oauth2 token's user's ID
|
// get oauth2 token's user's ID
|
||||||
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
|
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(req.Context(), authToken)
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
|
log.Trace("Basic Authorization: Valid OAuthAccessToken for user[%d]", uid)
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
|
|||||||
|
|
||||||
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
|
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
|
||||||
store.GetData()["ApiTokenScope"] = accessTokenScope
|
store.GetData()["ApiTokenScope"] = accessTokenScope
|
||||||
|
setAuthCredential(store, credentialOAuth2Grant, grantID)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +106,7 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
|
|||||||
|
|
||||||
store.GetData()["LoginMethod"] = AccessTokenMethodName
|
store.GetData()["LoginMethod"] = AccessTokenMethodName
|
||||||
store.GetData()["ApiTokenScope"] = token.Scope
|
store.GetData()["ApiTokenScope"] = token.Scope
|
||||||
|
setAuthCredential(store, credentialAccessToken, token.ID)
|
||||||
return u, nil
|
return u, nil
|
||||||
} else if !errors.Is(err, util.ErrNotExist) {
|
} else if !errors.Is(err, util.ErrNotExist) {
|
||||||
log.Error("GetAccessTokenBySHA: %v", err)
|
log.Error("GetAccessTokenBySHA: %v", err)
|
||||||
@@ -180,6 +184,8 @@ func validateTOTP(req *http.Request, u *user_model.User) error {
|
|||||||
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
|
if ok, err := twofa.ValidateAndConsumeTOTP(req.Context(), req.Header.Get("X-Gitea-OTP")); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
|
audit.RecordAs(req.Context(), u, audit_model.UserAuthenticationFailTwoFactor, u)
|
||||||
|
|
||||||
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
return util.NewInvalidArgumentErrorf("invalid provided OTP")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.dev/modules/web/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credential kinds naming how a request authenticated, recorded so an audit
|
||||||
|
// event can point at the token rather than only at its owner.
|
||||||
|
const (
|
||||||
|
credentialAccessToken = "access-token"
|
||||||
|
credentialOAuth2Grant = "oauth2-grant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setAuthCredential(store DataStore, kind string, id int64) {
|
||||||
|
store.GetData()[middleware.ContextDataKeyAuthCredential] = kind + ":" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
@@ -42,6 +42,17 @@ func ImpersonateUser(sess SessionStore, u *user_model.User) error {
|
|||||||
return sess.Release()
|
return sess.Release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImpersonatorUserID returns the ID of the admin behind an impersonated
|
||||||
|
// session, or zero when the session is not impersonating anyone.
|
||||||
|
func ImpersonatorUserID(sess SessionStore) int64 {
|
||||||
|
data, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
uid, _ := data[session.KeyUID].(int64)
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
func ExitImpersonatedUser(sess SessionStore) (bool, error) {
|
func ExitImpersonatedUser(sess SessionStore) (bool, error) {
|
||||||
impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
|
impersonatorData, ok := sess.Get(session.KeyImpersonatorData).(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+13
-10
@@ -25,35 +25,36 @@ import (
|
|||||||
|
|
||||||
var _ Method = &OAuth2{}
|
var _ Method = &OAuth2{}
|
||||||
|
|
||||||
// GetOAuthAccessTokenScopeAndUserID returns access token scope and user id
|
// GetOAuthAccessTokenScopeAndUserID returns access token scope, user id and the
|
||||||
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (auth_model.AccessTokenScope, int64) {
|
// grant the token was issued for.
|
||||||
|
func GetOAuthAccessTokenScopeAndUserID(ctx context.Context, accessToken string) (_ auth_model.AccessTokenScope, userID, grantID int64) {
|
||||||
var accessTokenScope auth_model.AccessTokenScope
|
var accessTokenScope auth_model.AccessTokenScope
|
||||||
if !setting.OAuth2.Enabled {
|
if !setting.OAuth2.Enabled {
|
||||||
return accessTokenScope, 0
|
return accessTokenScope, 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT tokens require a ".", if the token isn't like that, return early
|
// JWT tokens require a ".", if the token isn't like that, return early
|
||||||
if !strings.Contains(accessToken, ".") {
|
if !strings.Contains(accessToken, ".") {
|
||||||
return accessTokenScope, 0
|
return accessTokenScope, 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
|
token, err := oauth2_provider.ParseToken(accessToken, oauth2_provider.DefaultSigningKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace("oauth2.ParseToken: %v", err)
|
log.Trace("oauth2.ParseToken: %v", err)
|
||||||
return accessTokenScope, 0
|
return accessTokenScope, 0, 0
|
||||||
}
|
}
|
||||||
var grant *auth_model.OAuth2Grant
|
var grant *auth_model.OAuth2Grant
|
||||||
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
|
if grant, err = auth_model.GetOAuth2GrantByID(ctx, token.GrantID); err != nil || grant == nil {
|
||||||
return accessTokenScope, 0
|
return accessTokenScope, 0, 0
|
||||||
}
|
}
|
||||||
if token.Kind != oauth2_provider.KindAccessToken {
|
if token.Kind != oauth2_provider.KindAccessToken {
|
||||||
return accessTokenScope, 0
|
return accessTokenScope, 0, 0
|
||||||
}
|
}
|
||||||
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
|
if token.ExpiresAt.Before(time.Now()) || token.IssuedAt.After(time.Now()) {
|
||||||
return accessTokenScope, 0
|
return accessTokenScope, 0, 0
|
||||||
}
|
}
|
||||||
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
|
accessTokenScope = oauth2_provider.GrantAdditionalScopes(grant.Scope)
|
||||||
return accessTokenScope, grant.UserID
|
return accessTokenScope, grant.UserID, grant.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
|
// CheckTaskIsRunning verifies that the TaskID corresponds to a running task
|
||||||
@@ -118,9 +119,10 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, check if this is an OAuth access token
|
// Otherwise, check if this is an OAuth access token
|
||||||
accessTokenScope, uid := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
|
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
store.GetData()["ApiTokenScope"] = accessTokenScope
|
store.GetData()["ApiTokenScope"] = accessTokenScope
|
||||||
|
setAuthCredential(store, credentialOAuth2Grant, grantID)
|
||||||
}
|
}
|
||||||
return user_model.GetUserByID(ctx, uid)
|
return user_model.GetUserByID(ctx, uid)
|
||||||
}
|
}
|
||||||
@@ -141,6 +143,7 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
|
|||||||
log.Error("UpdateAccessToken: %v", err)
|
log.Error("UpdateAccessToken: %v", err)
|
||||||
}
|
}
|
||||||
store.GetData()["ApiTokenScope"] = t.Scope
|
store.GetData()["ApiTokenScope"] = t.Scope
|
||||||
|
setAuthCredential(store, credentialAccessToken, t.ID)
|
||||||
return user_model.GetUserByID(ctx, t.UID)
|
return user_model.GetUserByID(ctx, t.UID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"uuid"
|
"uuid"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
"gitea.dev/modules/session"
|
"gitea.dev/modules/session"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ensure the struct implements the interface.
|
// Ensure the struct implements the interface.
|
||||||
@@ -171,5 +173,7 @@ func (r *ReverseProxy) newUser(req *http.Request) *user_model.User {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audit.RecordAs(req.Context(), user_model.NewAuthenticationSourceUser(), audit_model.UserCreate, user)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-2
@@ -6,11 +6,37 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
audit_model "gitea.dev/models/audit"
|
||||||
"gitea.dev/models/auth"
|
"gitea.dev/models/auth"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/services/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CreateSource creates a AuthSource record in DB.
|
||||||
|
func CreateSource(ctx context.Context, source *auth.Source) error {
|
||||||
|
if err := auth.CreateSource(ctx, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.SystemAuthenticationSourceAdd, nil,
|
||||||
|
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSource updates a AuthSource record in DB.
|
||||||
|
func UpdateSource(ctx context.Context, source *auth.Source) error {
|
||||||
|
if err := auth.UpdateSource(ctx, source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.SystemAuthenticationSourceUpdate, nil,
|
||||||
|
"auth_source", source.Name, "auth_source_type", source.Type.String(), "is_active", source.IsActive)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteSource deletes a AuthSource record in DB.
|
// DeleteSource deletes a AuthSource record in DB.
|
||||||
func DeleteSource(ctx context.Context, source *auth.Source) error {
|
func DeleteSource(ctx context.Context, source *auth.Source) error {
|
||||||
count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
|
count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
|
||||||
@@ -37,6 +63,11 @@ func DeleteSource(ctx context.Context, source *auth.Source) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
|
if _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.Record(ctx, audit_model.SystemAuthenticationSourceRemove, nil, "auth_source", source.Name)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user