diff --git a/cmd/admin_user.go b/cmd/admin_user.go index b45aee1895d2e..01fe95f2dc7a4 100644 --- a/cmd/admin_user.go +++ b/cmd/admin_user.go @@ -19,6 +19,7 @@ func newUserCommand() *cli.Command { newUserGenerateAccessTokenCommand(), microcmdUserMustChangePassword(), microcmdUserDisableTwoFactor(), + microcmdUserChangeType(), }, } } diff --git a/cmd/admin_user_change_password_test.go b/cmd/admin_user_change_password_test.go index 50fd8d9e4dd0c..1ed1885b4c971 100644 --- a/cmd/admin_user_change_password_test.go +++ b/cmd/admin_user_change_password_test.go @@ -20,6 +20,7 @@ func TestChangePasswordCommand(t *testing.T) { defer func() { require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{})) }() t.Run("change password successfully", func(t *testing.T) { diff --git a/cmd/admin_user_change_type.go b/cmd/admin_user_change_type.go new file mode 100644 index 0000000000000..d25d2252d2e12 --- /dev/null +++ b/cmd/admin_user_change_type.go @@ -0,0 +1,61 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + + user_model "gitea.dev/models/user" + "gitea.dev/modules/setting" + user_service "gitea.dev/services/user" + + "github.com/urfave/cli/v3" +) + +func microcmdUserChangeType() *cli.Command { + return &cli.Command{ + Name: "change-type", + Usage: "Convert a user between the individual and bot types", + Action: runChangeUserType, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Usage: "The user to convert", + Required: true, + }, + &cli.StringFlag{ + Name: "user-type", + Usage: "New user type: individual or bot", + Required: true, + }, + }, + } +} + +func runChangeUserType(ctx context.Context, c *cli.Command) error { + targetType, err := user_model.ParseUserType(c.String("user-type")) + if err != nil { + return err + } + + if !setting.IsInTesting { + if err := initDB(ctx); err != nil { + return err + } + } + + user, err := user_model.GetUserByName(ctx, c.String("username")) + if err != nil { + return err + } + + if err := user_service.ConvertUserType(ctx, user, targetType); err != nil { + return err + } + + fmt.Printf("%s's type has been successfully changed to %s!\n", user.Name, c.String("user-type")) + return nil +} diff --git a/cmd/admin_user_change_type_test.go b/cmd/admin_user_change_type_test.go new file mode 100644 index 0000000000000..bff9835dc90b9 --- /dev/null +++ b/cmd/admin_user_change_type_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "io" + "testing" + + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChangeTypeCommand(t *testing.T) { + ctx := t.Context() + + defer func() { + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{})) + }() + + t.Run("convert individual to bot and back", func(t *testing.T) { + require.NoError(t, microcmdUserCreate().Run(ctx, []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"})) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, user.IsIndividual()) + + require.NoError(t, microcmdUserChangeType().Run(ctx, []string{"change-type", "--username", "testuser", "--user-type", "bot"})) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, user.IsTypeBot()) + assert.Empty(t, user.Passwd) + + require.NoError(t, microcmdUserChangeType().Run(ctx, []string{"change-type", "--username", "testuser", "--user-type", "individual"})) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "testuser"}) + assert.True(t, user.IsIndividual()) + }) + + t.Run("failure cases", func(t *testing.T) { + testCases := []struct { + name string + args []string + expectedErr string + }{ + { + name: "invalid user type", + args: []string{"change-type", "--username", "testuser", "--user-type", "invalid"}, + expectedErr: "invalid user type", + }, + { + name: "missing username", + args: []string{"change-type", "--user-type", "bot"}, + expectedErr: `"username" not set`, + }, + { + name: "missing user-type", + args: []string{"change-type", "--username", "testuser"}, + expectedErr: `"user-type" not set`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cmd := microcmdUserChangeType() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, tc.args) + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + }) + } + }) +} diff --git a/cmd/admin_user_create.go b/cmd/admin_user_create.go index 2db926d27afe2..4b3aa44da0769 100644 --- a/cmd/admin_user_create.go +++ b/cmd/admin_user_create.go @@ -105,13 +105,9 @@ func runCreateUser(ctx context.Context, c *cli.Command) error { // duplicate setting loading should be safe at the moment, but it should be refactored & improved in the future. setting.LoadSettings() - userTypes := map[string]user_model.UserType{ - "individual": user_model.UserTypeIndividual, - "bot": user_model.UserTypeBot, - } - userType, ok := userTypes[c.String("user-type")] - if !ok { - return fmt.Errorf("invalid user type: %s", c.String("user-type")) + userType, err := user_model.ParseUserType(c.String("user-type")) + if err != nil { + return err } if userType != user_model.UserTypeIndividual { // Some other commands like "change-password" also only support individual users. @@ -120,6 +116,10 @@ func runCreateUser(ctx context.Context, c *cli.Command) error { if c.IsSet("password") || c.IsSet("random-password") { return errors.New("password can only be set for individual users") } + // automation does not need site-wide root access + if c.Bool("admin") { + return errors.New("admin flag can only be set for individual users") + } } if c.IsSet("password") && c.IsSet("random-password") { diff --git a/cmd/admin_user_create_test.go b/cmd/admin_user_create_test.go index ece2e8869d504..7a710f73a7190 100644 --- a/cmd/admin_user_create_test.go +++ b/cmd/admin_user_create_test.go @@ -58,6 +58,7 @@ func TestAdminUserCreate(t *testing.T) { assert.ErrorContains(t, createUser("u", "--user-type", "invalid"), "invalid user type") assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--password", "123"), "can only be set for individual users") assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--must-change-password"), "can only be set for individual users") + assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--admin"), "can only be set for individual users") assert.NoError(t, createUser("u", "--user-type", "bot")) u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"}) diff --git a/cmd/admin_user_must_change_password_test.go b/cmd/admin_user_must_change_password_test.go index cf81fbe0ba780..5a7fad8b134b4 100644 --- a/cmd/admin_user_must_change_password_test.go +++ b/cmd/admin_user_must_change_password_test.go @@ -17,6 +17,7 @@ import ( func TestMustChangePassword(t *testing.T) { defer func() { require.NoError(t, db.TruncateBeans(t.Context(), &user_model.User{})) + require.NoError(t, db.TruncateBeans(t.Context(), &user_model.EmailAddress{})) }() err := microcmdUserCreate().Run(t.Context(), []string{"create", "--username", "testuser", "--email", "testuser@gitea.local", "--random-password"}) require.NoError(t, err) diff --git a/models/activities/notification.go b/models/activities/notification.go index 188106faace03..de37349273a8d 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -126,15 +126,18 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo return err } for i := range users { + if users[i].IsTypeBot() { + continue + } notify = append(notify, &Notification{ - UserID: i, + UserID: users[i].ID, RepoID: repo.ID, Status: NotificationStatusUnread, UpdatedBy: doer.ID, Source: NotificationSourceRepository, }) } - } else { + } else if !newOwner.IsTypeBot() { notify = []*Notification{{ UserID: newOwner.ID, RepoID: repo.ID, @@ -144,6 +147,9 @@ func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_mo }} } + if len(notify) == 0 { + return nil + } return db.Insert(ctx, notify) }) } diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go index e344b7f21de15..9353ff5d5f6cf 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -168,6 +168,9 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n return nil, err } + if user.IsTypeBot() { + continue + } if issue.IsPull && !access_model.CheckRepoUnitUser(ctx, issue.Repo, user, unit.TypePullRequests) { continue } diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go index 1438afb3cce8c..6973af6ea3096 100644 --- a/models/activities/notification_test.go +++ b/models/activities/notification_test.go @@ -33,6 +33,26 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) { assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status) } +func TestCreateRepoTransferNotificationOrgSkipsBot(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + assert.True(t, org.IsOrganization()) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) + + // user28 and user2 can both create repos in org3; as a bot, user28 is skipped + bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 28}) + bot.Type = user_model.UserTypeBot + assert.NoError(t, user_model.UpdateUserCols(t.Context(), bot, "type")) + + assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), doer, org, repo)) + + // the non-bot member is notified under its real user id, the bot is not notified at all + notf := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 2, RepoID: repo.ID, Source: activities_model.NotificationSourceRepository}) + assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status) + unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: bot.ID, RepoID: repo.ID, Source: activities_model.NotificationSourceRepository}) +} + func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -66,6 +86,34 @@ func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) { assert.Empty(t, notified) } +func TestCreateOrUpdateIssueNotificationsSkipsBots(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + user.Type = user_model.UserTypeBot + assert.NoError(t, user_model.UpdateUserCols(t.Context(), user, "type")) + + notifiedIDs, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 2, 0) + assert.NoError(t, err) + assert.NotContains(t, notifiedIDs, user.ID) + + unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: issue.ID}) + unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: user.ID, IssueID: issue.ID}) +} + +func TestCreateRepoTransferNotificationSkipsBot(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + newOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + newOwner.Type = user_model.UserTypeBot + assert.NoError(t, user_model.UpdateUserCols(t.Context(), newOwner, "type")) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + assert.NoError(t, activities_model.CreateRepoTransferNotification(t.Context(), doer, newOwner, repo)) + + unittest.AssertNotExistsBean(t, &activities_model.Notification{UserID: newOwner.ID, RepoID: repo.ID, Source: activities_model.NotificationSourceRepository}) +} + func TestNotificationsForUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/models/user/error.go b/models/user/error.go index 28ea4f21c1c23..3feed73da31e8 100644 --- a/models/user/error.go +++ b/models/user/error.go @@ -86,3 +86,8 @@ func IsErrUserIsNotLocal(err error) bool { _, ok := err.(ErrUserIsNotLocal) return ok } + +var ( + ErrBotCanNotBeAdmin = util.NewInvalidArgumentErrorf("bot user can not be a site administrator") + ErrUserTypeCanNotConvert = util.NewInvalidArgumentErrorf("user type can not be converted") +) diff --git a/models/user/user.go b/models/user/user.go index 77da0fcdf2f0f..f561f3d6621b3 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -68,6 +68,22 @@ const ( UserTypeRemoteUser // 5 ) +// convertibleUserTypes maps the user types an admin may create or convert between. +// Only these types have a stable external name, the other ones are internal. +var convertibleUserTypes = map[string]UserType{ + "individual": UserTypeIndividual, + "bot": UserTypeBot, +} + +// ParseUserType maps an external user type name to its UserType. +func ParseUserType(s string) (UserType, error) { + t, ok := convertibleUserTypes[s] + if !ok { + return 0, util.NewInvalidArgumentErrorf("invalid user type %q, must be one of: individual, bot", s) + } + return t, nil +} + const ( // EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own EmailNotificationsEnabled = "enabled" @@ -518,6 +534,9 @@ func (u *User) GitName() string { } // IsMailable checks if a user is eligible to receive emails. +// Bots (including the Gitea Actions user) and the Ghost user are excluded: +// they have no inbox to read. IsIndividual() rules out bot accounts; ID > 0 +// rules out the ghost. func (u *User) IsMailable() bool { return u.ID > 0 && u.IsActive && u.IsIndividual() } diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 9795e83b5dbfe..7a0c33cc572c0 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -77,3 +77,11 @@ type EditUserOption struct { // User visibility level: public, limited, or private Visibility VisibilityString `json:"visibility" binding:"In(,public,limited,private)"` } + +// ConvertUserTypeOption options when converting a user between individual and bot +type ConvertUserTypeOption struct { + // The target user type: "individual" or "bot" + // + // required: true + UserType string `json:"user_type" binding:"Required;In(individual,bot)"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 6cb8a033a0edf..e3496a9408f0b 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -870,6 +870,7 @@ "settings.permission_everyone_write": "Everyone Write", "settings.access_token_desc": "Selected token permissions limit authorization only to the corresponding API routes. Read the documentation for more information.", "settings.at_least_one_permission": "You must select at least one permission to create a token", + "settings.token_admin_scope_not_allowed": "A token for this account cannot include administrator permissions.", "settings.permissions_list": "Permissions:", "settings.manage_oauth2_applications": "Manage OAuth2 Applications", "settings.edit_oauth2_application": "Edit OAuth2 Application", @@ -3060,6 +3061,7 @@ "admin.users.admin": "Admin", "admin.users.restricted": "Restricted", "admin.users.reserved": "Reserved", + "admin.users.individual": "Individual", "admin.users.bot": "Bot", "admin.users.remote": "Remote", "admin.users.2fa": "2FA", @@ -3073,6 +3075,22 @@ "admin.users.impersonate": "Impersonate", "admin.users.impersonate_stop": "Stop impersonating", "admin.users.impersonating_notice": "You are impersonating %s. Actions you take are performed as this user.", + "admin.users.user_type": "User Type", + "admin.users.user_type.invalid": "Invalid user type.", + "admin.users.convert_type": "Convert User Type", + "admin.users.convert_type.to_bot": "Convert to Bot User", + "admin.users.convert_type.to_bot_desc": "Convert this account into a bot user. The account becomes non-interactive: it cannot sign in and its credentials and notifications are permanently deleted. Access tokens are then managed by administrators.", + "admin.users.convert_type.to_bot_notice": "This account will no longer be able to sign in. Its password, two-factor methods, authentication source, OAuth2 applications and grants, OpenID identities and all of its notifications will be permanently deleted. Converting the account back to an individual user does not restore them. Its access tokens and everything it owns are kept.", + "admin.users.convert_type.to_individual": "Convert to Individual User", + "admin.users.convert_type.to_individual_desc": "Convert this bot account back into an individual user. A password must be set afterwards before the account can sign in.", + "admin.users.convert_type.to_individual_notice": "This account will become an individual user again. A password must be set afterwards before it can sign in.", + "admin.users.convert_type.self_not_allowed": "You cannot convert your own account.", + "admin.users.convert_type.admin_not_allowed": "Administrators cannot be converted into bot accounts. Remove the administrator permission first.", + "admin.users.bot_no_admin": "Bot accounts cannot be site administrators.", + "admin.users.bot_token_desc": "Bot accounts cannot sign in, so their access tokens are managed here by administrators.", + "admin.users.bot_token_only": "Access tokens can only be generated for bot accounts here.", + "admin.users.bot_no_password": "Bot accounts are non-interactive and cannot have a password.", + "admin.users.impersonate_bot_not_allowed": "Bot accounts are non-interactive and cannot be impersonated.", "admin.users.auth_source": "Authentication Source", "admin.users.local": "Local", "admin.users.auth_login_name": "Authentication Sign-In Name", @@ -3099,6 +3117,7 @@ "admin.users.still_own_packages": "This user still owns one or more packages. Delete these packages first.", "admin.users.deletion_success": "The user account has been deleted.", "admin.users.reset_2fa": "Reset 2FA", + "admin.users.list_type_filter.menu_text": "User Type", "admin.users.list_status_filter.menu_text": "Filter", "admin.users.list_status_filter.reset": "Reset", "admin.users.list_status_filter.is_active": "Active", @@ -3963,5 +3982,6 @@ "actions.general.cross_repo_selected": "Selected repositories", "actions.general.cross_repo_target_repos": "Target Repositories", "actions.general.cross_repo_add": "Add Target Repository", - "packages.owner.settings.cleanuprules.type.already_exists": "A cleanup rule for this package type already exists." + "packages.owner.settings.cleanuprules.type.already_exists": "A cleanup rule for this package type already exists.", + "admin.users.convert_type.not_convertible": "This user type cannot be converted. Only individual and bot accounts support type conversion." } diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index d7cd45fd3c45f..d973b988ba38b 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -208,7 +208,7 @@ func EditUser(ctx *context.APIContext) { case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err): ctx.APIError(http.StatusBadRequest, err.Error()) default: - ctx.APIErrorInternal(err) + ctx.APIErrorAuto(err) } return } @@ -249,7 +249,7 @@ func EditUser(ctx *context.APIContext) { if user_model.IsErrDeleteLastAdminUser(err) { ctx.APIError(http.StatusBadRequest, err.Error()) } else { - ctx.APIErrorInternal(err) + ctx.APIErrorAuto(err) } return } @@ -564,3 +564,51 @@ func RenameUser(ctx *context.APIContext) { } ctx.Status(http.StatusNoContent) } + +// ConvertUserType converts a user between the individual and bot types +func ConvertUserType(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/convert-type admin adminConvertUserType + // --- + // summary: Convert a user between the individual and bot types + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user to convert + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/ConvertUserTypeOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if ctx.ContextUser.ID == ctx.Doer.ID { + ctx.APIError(http.StatusBadRequest, "the own account type can not be converted") + return + } + + targetType, err := user_model.ParseUserType(web.GetForm[*api.ConvertUserTypeOption](ctx).UserType) + if err != nil { + ctx.APIErrorAuto(err) + return + } + + if err := user_service.ConvertUserType(ctx, ctx.ContextUser, targetType); err != nil { + ctx.APIErrorAuto(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/admin/user_test.go b/routers/api/v1/admin/user_test.go new file mode 100644 index 0000000000000..151778a1090eb --- /dev/null +++ b/routers/api/v1/admin/user_test.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "testing" + + user_model "gitea.dev/models/user" + api "gitea.dev/modules/structs" + "gitea.dev/modules/web" + "gitea.dev/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestConvertUserTypeRejectsNonConvertibleTarget(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "POST /api/v1/admin/users/remote/convert-type") + ctx.Doer = &user_model.User{ID: 1} + ctx.ContextUser = &user_model.User{ID: 2, Type: user_model.UserTypeRemoteUser} + web.SetForm(ctx, &api.ConvertUserTypeOption{UserType: "bot"}) + + ConvertUserType(ctx) + + assert.Equal(t, http.StatusBadRequest, ctx.Resp.WrittenStatus()) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 51fca8e85c62f..ae635eb3d327f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1896,6 +1896,7 @@ func Routes() *web.Router { m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg) m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser) + m.Post("/convert-type", bind(api.ConvertUserTypeOption{}), admin.ConvertUserType) m.Get("/badges", admin.ListUserBadges) m.Post("/badges", bind(api.UserBadgeOption{}), admin.AddUserBadges) m.Delete("/badges", bind(api.UserBadgeOption{}), admin.DeleteUserBadges) diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 0522fcec68069..6a1eaaede6097 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -59,6 +59,9 @@ type swaggerParameterBodies struct { // in:body RenameUserOption api.RenameUserOption + // in:body + ConvertUserTypeOption api.ConvertUserTypeOption + // in:body CreateLabelOption api.CreateLabelOption // in:body diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index f9ae597527803..074297f7690fb 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -6,6 +6,7 @@ package admin import ( "errors" + "html/template" "net/http" "net/url" "strconv" @@ -23,6 +24,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/structs" "gitea.dev/modules/templates" + "gitea.dev/modules/util" "gitea.dev/modules/web" "gitea.dev/routers/web/explore" user_setting "gitea.dev/routers/web/user/setting" @@ -57,14 +59,24 @@ func Users(ctx *context.Context) { } sortType := ctx.FormString("sort", UserSearchDefaultAdminSort) + + userTypeFilter := ctx.FormString("user_type") + types := []user_model.UserType{user_model.UserTypeIndividual} + if t, err := user_model.ParseUserType(userTypeFilter); err == nil { + types = []user_model.UserType{t} + } else { + userTypeFilter = "" // normalize unknown values so the UI doesn't show a filter that isn't applied + } + ctx.PageData["adminUserListSearchForm"] = map[string]any{ "StatusFilterMap": statusFilterMap, + "UserTypeFilter": userTypeFilter, "SortType": sortType, } explore.RenderUserSearch(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, - Types: []user_model.UserType{user_model.UserTypeIndividual}, + Types: types, ListOptions: db.ListOptions{ PageSize: setting.UI.Admin.UserPagingNum, }, @@ -74,8 +86,9 @@ func Users(ctx *context.Context) { IsRestricted: optional.ParseBool(statusFilterMap["is_restricted"]), IsTwoFactorEnabled: optional.ParseBool(statusFilterMap["is_2fa_enabled"]), IsProhibitLogin: optional.ParseBool(statusFilterMap["is_prohibit_login"]), - IncludeReserved: true, // administrator needs to list all accounts include reserved, bot, remote ones - OrderBy: db.SearchOrderBy(sortType), + // unfiltered, an administrator needs to list all accounts including reserved, bot and remote ones + IncludeReserved: userTypeFilter == "", + OrderBy: db.SearchOrderBy(sortType), }, tplUsers) } @@ -87,6 +100,7 @@ func NewUser(ctx *context.Context) { ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() ctx.Data["login_type"] = "0-0" + ctx.Data["UserType"] = "individual" sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{ IsActive: optional.Some(true), @@ -119,6 +133,7 @@ func NewUserPost(ctx *context.Context) { ctx.Data["Sources"] = sources ctx.Data["CanSendEmail"] = setting.MailService != nil + ctx.Data["UserType"] = form.UserType if ctx.HasError() { ctx.HTML(http.StatusOK, tplUserNew) @@ -137,65 +152,48 @@ func NewUserPost(ctx *context.Context) { Visibility: &form.Visibility, } - if len(form.LoginType) > 0 { - fields := strings.Split(form.LoginType, "-") - if len(fields) == 2 { - lType, _ := strconv.ParseInt(fields[0], 10, 0) - u.LoginType = auth.Type(lType) - u.LoginSource, _ = strconv.ParseInt(fields[1], 10, 64) - u.LoginName = form.LoginName - } - } - if u.LoginType == auth.NoType || u.LoginType == auth.Plain { - if len(form.Password) < setting.MinPasswordLength { - ctx.Data["Err_Password"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form) - return - } - if !password.IsComplexEnough(form.Password) { - ctx.Data["Err_Password"] = true - ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplUserNew, &form) - return + // Bot users are created as local accounts without a password or auth source, + // matching the behavior of the "gitea admin user create --user-type bot" command. + if form.UserType == "bot" { + u.Type = user_model.UserTypeBot + u.Passwd = "" + } else { + if len(form.LoginType) > 0 { + fields := strings.Split(form.LoginType, "-") + if len(fields) == 2 { + lType, _ := strconv.ParseInt(fields[0], 10, 0) + u.LoginType = auth.Type(lType) + u.LoginSource, _ = strconv.ParseInt(fields[1], 10, 64) + u.LoginName = form.LoginName + } } - if err := password.IsPwned(ctx, form.Password); err != nil { - ctx.Data["Err_Password"] = true - errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords") - if password.IsErrIsPwnedRequest(err) { - log.Error(err.Error()) - errMsg = ctx.Tr("auth.password_pwned_err") + if u.LoginType == auth.NoType || u.LoginType == auth.Plain { + if len(form.Password) < setting.MinPasswordLength { + ctx.Data["Err_Password"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_too_short", setting.MinPasswordLength), tplUserNew, &form) + return } - ctx.RenderWithErrDeprecated(errMsg, tplUserNew, &form) - return + if !password.IsComplexEnough(form.Password) { + ctx.Data["Err_Password"] = true + ctx.RenderWithErrDeprecated(password.BuildComplexityError(ctx.Locale), tplUserNew, &form) + return + } + if err := password.IsPwned(ctx, form.Password); err != nil { + ctx.Data["Err_Password"] = true + errMsg := ctx.Tr("auth.password_pwned", "https://haveibeenpwned.com/Passwords") + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + errMsg = ctx.Tr("auth.password_pwned_err") + } + ctx.RenderWithErrDeprecated(errMsg, tplUserNew, &form) + return + } + u.MustChangePassword = form.MustChangePassword } - u.MustChangePassword = form.MustChangePassword } if err := user_model.AdminCreateUser(ctx, u, &user_model.Meta{}, overwriteDefault); err != nil { - var errNameReserved db.ErrNameReserved - var errNamePatternNotAllowed db.ErrNamePatternNotAllowed - var errNameCharsNotAllowed db.ErrNameCharsNotAllowed - switch { - case user_model.IsErrUserAlreadyExist(err): - ctx.Data["Err_UserName"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tplUserNew, &form) - case user_model.IsErrEmailAlreadyUsed(err): - ctx.Data["Err_Email"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplUserNew, &form) - case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err): - ctx.Data["Err_Email"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplUserNew, &form) - case errors.As(err, &errNameReserved): - ctx.Data["Err_UserName"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", errNameReserved.Name), tplUserNew, &form) - case errors.As(err, &errNamePatternNotAllowed): - ctx.Data["Err_UserName"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", errNamePatternNotAllowed.Pattern), tplUserNew, &form) - case errors.As(err, &errNameCharsNotAllowed): - ctx.Data["Err_UserName"] = true - ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_chars_not_allowed", errNameCharsNotAllowed.Name), tplUserNew, &form) - default: - ctx.ServerError("CreateUser", err) - } + handleAdminCreateUserError(ctx, err, form) return } @@ -214,6 +212,35 @@ func NewUserPost(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(u.ID, 10)) } +// handleAdminCreateUserError renders the new-user page with a field-specific error message +func handleAdminCreateUserError(ctx *context.Context, err error, form *forms.AdminCreateUserForm) { + var nameReserved db.ErrNameReserved + var namePatternNotAllowed db.ErrNamePatternNotAllowed + var nameCharsNotAllowed db.ErrNameCharsNotAllowed + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("form.username_been_taken"), tplUserNew, form) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("form.email_been_used"), tplUserNew, form) + case user_model.IsErrEmailInvalid(err), user_model.IsErrEmailCharIsNotSupported(err): + ctx.Data["Err_Email"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("form.email_invalid"), tplUserNew, form) + case errors.As(err, &nameReserved): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_reserved", nameReserved.Name), tplUserNew, form) + case errors.As(err, &namePatternNotAllowed): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_pattern_not_allowed", namePatternNotAllowed.Pattern), tplUserNew, form) + case errors.As(err, &nameCharsNotAllowed): + ctx.Data["Err_UserName"] = true + ctx.RenderWithErrDeprecated(ctx.Tr("user.form.name_chars_not_allowed", nameCharsNotAllowed.Name), tplUserNew, form) + default: + ctx.ServerError("CreateUser", err) + } +} + func prepareUserInfo(ctx *context.Context) *user_model.User { u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) if err != nil { @@ -254,10 +281,25 @@ func prepareUserInfo(ctx *context.Context) *user_model.User { return nil } ctx.Data["TwoFactorEnabled"] = hasTOTP || hasWebAuthn + // an admin must not convert their own account: it would drop their credentials and sign them out + ctx.Data["CanConvertUserType"] = u.ID != ctx.Doer.ID && user_service.CheckConvertUserType(u) == nil return u } +// botAccessTokensData feeds shared/user/access_tokens for the bot token management section +type botAccessTokensData struct { + Description template.HTML + Tokens []*auth.AccessToken + ScopeCategories []string + ScopePublicOnly auth.AccessTokenScope + CreateURL string + DeleteURL string + RegenerateURL string // empty: an admin rotates a bot token by deleting and recreating it + NameValue string + ErrName bool +} + func ViewUser(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users.details") ctx.Data["PageIsAdminUsers"] = true @@ -306,9 +348,102 @@ func ViewUser(ctx *context.Context) { ctx.Data["Users"] = orgs // needed to be able to use explore/user_list template ctx.Data["OrgsTotal"] = len(orgs) + // Bot users cannot sign in to generate their own tokens, so admins manage them here. + if u.IsTypeBot() { + botTokens, err := db.Find[auth.AccessToken](ctx, auth.ListAccessTokensOptions{UserID: u.ID}) + if err != nil { + ctx.ServerError("ListAccessTokens", err) + return + } + ctx.Data["BotAccessTokens"] = &botAccessTokensData{ + Description: ctx.Tr("admin.users.bot_token_desc"), + Tokens: botTokens, + // a bot can never be a site admin, so an admin-scoped token would be useless + ScopeCategories: util.SliceRemoveAll(auth.GetAccessTokenCategories(), "admin"), + ScopePublicOnly: auth.AccessTokenScopePublicOnly, + CreateURL: ctx.Link + "/access_tokens", + DeleteURL: ctx.Link + "/access_tokens/delete", + } + } + ctx.HTML(http.StatusOK, tplUserView) } +// getTargetUser loads the user an admin action operates on, without the page data prepareUserInfo collects +func getTargetUser(ctx *context.Context) *user_model.User { + u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid")) + if err != nil { + ctx.NotFoundOrServerError("GetUserByID", user_model.IsErrUserNotExist, err) + return nil + } + return u +} + +// NewBotTokenPost creates an access token for a bot user on behalf of an admin +func NewBotTokenPost(ctx *context.Context) { + form := web.GetForm[*forms.NewAccessTokenForm](ctx) + u := getTargetUser(ctx) + if ctx.Written() { + return + } + + redirect := setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(u.ID, 10) + if !u.IsTypeBot() { + ctx.Flash.Error(ctx.Tr("admin.users.bot_token_only")) + ctx.Redirect(redirect) + return + } + + if ctx.HasError() { + ctx.Flash.Error(ctx.GetErrMsg()) + ctx.Redirect(redirect) + return + } + + t, err := user_setting.NewAccessTokenFromForm(ctx, u, form.Name, false) + switch { + case errors.Is(err, user_setting.ErrAccessTokenNoPermission): + ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission")) + case errors.Is(err, user_setting.ErrAccessTokenAdminScope): + ctx.Flash.Error(ctx.Tr("settings.token_admin_scope_not_allowed")) + case errors.Is(err, user_setting.ErrAccessTokenNameDuplicate): + ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", form.Name)) + case errors.Is(err, user_setting.ErrAccessTokenScopeEscalation): + ctx.HTTPError(http.StatusForbidden, err.Error()) + return + case err != nil: + ctx.ServerError("NewAccessTokenFromForm", err) + return + default: + ctx.Flash.Success(ctx.Tr("settings.generate_token_success")) + ctx.Flash.Info(t.Token) + } + ctx.Redirect(redirect) +} + +// DeleteBotToken deletes an access token of a bot user on behalf of an admin +func DeleteBotToken(ctx *context.Context) { + u := getTargetUser(ctx) + if ctx.Written() { + return + } + + redirect := setting.AppSubURL + "/-/admin/users/" + strconv.FormatInt(u.ID, 10) + // only bot tokens are managed here; regular users manage their own tokens + if !u.IsTypeBot() { + ctx.Flash.Error(ctx.Tr("admin.users.bot_token_only")) + ctx.JSONRedirect(redirect) + return + } + + if err := auth.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), u.ID); err != nil { + ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.delete_token_success")) + } + ctx.JSONRedirect(redirect) +} + func editUserCommon(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users.edit_account") ctx.Data["PageIsAdminUsers"] = true @@ -371,8 +506,7 @@ func EditUserPost(ctx *context.Context) { } authOpts := &user_service.UpdateAuthOptions{ - Password: optional.FromNonDefault(form.Password), - LoginName: optional.Some(form.LoginName), + Password: optional.FromNonDefault(form.Password), } // skip self Prohibit Login @@ -382,11 +516,13 @@ func EditUserPost(ctx *context.Context) { authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin) } + // the form omits both fields for bots, and an absent auth source must not clear the login name fields := strings.Split(form.LoginType, "-") if len(fields) == 2 { authSource, _ := strconv.ParseInt(fields[1], 10, 64) authOpts.LoginSource = optional.Some(authSource) + authOpts.LoginName = optional.Some(form.LoginName) } if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil { @@ -403,6 +539,8 @@ func EditUserPost(ctx *context.Context) { case password.IsErrIsPwnedRequest(err): ctx.Data["Err_Password"] = true ctx.RenderWithErrDeprecated(ctx.Tr("auth.password_pwned_err"), tplUserEdit, &form) + case errors.Is(err, util.ErrInvalidArgument): + ctx.RenderWithErrDeprecated(err.Error(), tplUserEdit, &form) default: ctx.ServerError("UpdateUser", err) } @@ -444,9 +582,13 @@ func EditUserPost(ctx *context.Context) { } if err := user_service.UpdateUser(ctx, u, opts); err != nil { - if user_model.IsErrDeleteLastAdminUser(err) { + switch { + case user_model.IsErrDeleteLastAdminUser(err): ctx.RenderWithErrDeprecated(ctx.Tr("auth.last_admin"), tplUserEdit, &form) - } else { + case errors.Is(err, user_model.ErrBotCanNotBeAdmin): + ctx.Flash.Error(ctx.Tr("admin.users.bot_no_admin")) + ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid"))) + default: ctx.ServerError("UpdateUser", err) } return @@ -470,6 +612,14 @@ func ImpersonateUser(ctx *context.Context) { ctx.JSONError("unable to get user") return } + + // Bot accounts are non-interactive; impersonating one would grant a session + // that signing in could never produce. + if u.IsTypeBot() { + ctx.JSONError(ctx.Tr("admin.users.impersonate_bot_not_allowed")) + return + } + err = auth_service.ImpersonateUser(ctx.Session, u) if err != nil { ctx.ServerError("unable to impersonate user", err) @@ -518,6 +668,49 @@ func DeleteUser(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/-/admin/users") } +// ConvertUserType converts a user between the individual and bot types. +func ConvertUserType(ctx *context.Context) { + u := getTargetUser(ctx) + if ctx.Written() { + return + } + + redirect := setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")) + "/edit" + + targetType, err := user_model.ParseUserType(ctx.FormString("user_type")) + if err != nil { + ctx.Flash.Error(ctx.Tr("admin.users.user_type.invalid")) + ctx.Redirect(redirect) + return + } + + if u.ID == ctx.Doer.ID { + ctx.Flash.Error(ctx.Tr("admin.users.convert_type.self_not_allowed")) + ctx.Redirect(redirect) + return + } + + if err := user_service.ConvertUserType(ctx, u, targetType); err != nil { + switch { + case errors.Is(err, user_model.ErrBotCanNotBeAdmin): + ctx.Flash.Error(ctx.Tr("admin.users.convert_type.admin_not_allowed")) + case errors.Is(err, user_model.ErrUserTypeCanNotConvert): + ctx.Flash.Error(ctx.Tr("admin.users.convert_type.not_convertible")) + case errors.Is(err, util.ErrInvalidArgument): + ctx.Flash.Error(ctx.Tr("admin.users.user_type.invalid")) + default: + ctx.ServerError("ConvertUserType", err) + return + } + ctx.Redirect(redirect) + return + } + + log.Trace("Account type converted by admin (%s): %s", ctx.Doer.Name, u.Name) + ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success")) + ctx.Redirect(redirect) +} + // AvatarPost response for change user's avatar request func AvatarPost(ctx *context.Context) { u := prepareUserInfo(ctx) diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index bdc4a0396e1b7..5f0fce99608d9 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -60,6 +60,11 @@ func ForgotPasswdPost(ctx *context.Context) { ctx.Data["Email"] = email u, err := user_model.GetUserByEmail(ctx, email) + // a bot has no password to reset, and its address may still be the one of the + // individual it was converted from, so it is treated like an unknown address + if err == nil && !u.IsIndividual() { + err = user_model.ErrUserNotExist{} + } if err != nil { if user_model.IsErrUserNotExist(err) { ctx.Data["ResetPwdCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ResetPwdCodeLives, ctx.Locale) @@ -112,7 +117,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto // Fail early, don't frustrate the user u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code) - if u == nil { + if u == nil || !u.IsIndividual() { // a code issued before a conversion must not set a password on the resulting bot ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", setting.AppSubURL+"/user/forgot_password"), true) return nil, nil } diff --git a/routers/web/user/setting/applications.go b/routers/web/user/setting/applications.go index b13b1718ce0d3..18722be523a28 100644 --- a/routers/web/user/setting/applications.go +++ b/routers/web/user/setting/applications.go @@ -5,11 +5,12 @@ package setting import ( + "errors" "net/http" - "strings" auth_model "gitea.dev/models/auth" "gitea.dev/models/db" + user_model "gitea.dev/models/user" "gitea.dev/modules/setting" "gitea.dev/modules/templates" "gitea.dev/modules/util" @@ -32,75 +33,103 @@ func Applications(ctx *context.Context) { ctx.HTML(http.StatusOK, tplSettingsApplications) } -// ApplicationsPost response for add user's access token -func ApplicationsPost(ctx *context.Context) { - form := web.GetForm[*forms.NewAccessTokenForm](ctx) - ctx.Data["Title"] = ctx.Tr("settings_title") - ctx.Data["PageIsSettingsApplications"] = true +var ( + // ErrAccessTokenNoPermission is returned when the submitted scope grants no permission at all + ErrAccessTokenNoPermission = errors.New("access token has no permission scope") + // ErrAccessTokenNameDuplicate is returned when the owner already has a token of that name + ErrAccessTokenNameDuplicate = errors.New("access token name already exists") + // ErrAccessTokenAdminScope is returned when an admin scope is requested for an owner that can never be a site administrator + ErrAccessTokenAdminScope = errors.New("access token cannot carry an admin scope") + // ErrAccessTokenScopeEscalation is returned when the authenticating token is narrower than the token it asks for + ErrAccessTokenScopeEscalation = errors.New("cannot create an access token with a broader scope than the authenticating token") +) +// NewAccessTokenFromForm creates an access token for owner from the submitted scope form. +// Pass allowAdminScope=false for owners that can never be a site administrator. +func NewAccessTokenFromForm(ctx *context.Context, owner *user_model.User, name string, allowAdminScope bool) (*auth_model.AccessToken, error) { _ = ctx.Req.ParseForm() - var scopeNames []string - const accessTokenScopePrefix = "scope-" - for k, v := range ctx.Req.Form { - if strings.HasPrefix(k, accessTokenScopePrefix) { - scopeNames = append(scopeNames, v...) - } - } - - scope, err := auth_model.AccessTokenScope(strings.Join(scopeNames, ",")).Normalize() + scope, err := forms.AccessTokenScopeFromForm(ctx.Req.Form).Normalize() if err != nil { - ctx.ServerError("GetScope", err) - return + return nil, err } if !scope.HasPermissionScope() { - ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) + return nil, ErrAccessTokenNoPermission } - - if ctx.HasError() { - loadApplicationsData(ctx) - ctx.HTML(http.StatusOK, tplSettingsApplications) - return + if !allowAdminScope { + hasAdminScope, err := scope.HasAnyScope(auth_model.AccessTokenScopeReadAdmin, auth_model.AccessTokenScopeWriteAdmin) + if err != nil { + return nil, err + } + if hasAdminScope { + return nil, ErrAccessTokenAdminScope + } } t := &auth_model.AccessToken{ - UID: ctx.Doer.ID, - Name: form.Name, + UID: owner.ID, + Name: name, Scope: scope, } exist, err := auth_model.AccessTokenByNameExists(ctx, t) if err != nil { - ctx.ServerError("AccessTokenByNameExists", err) - return + return nil, err } if exist { - ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name)) - ctx.Redirect(setting.AppSubURL + "/user/settings/applications") - return + return nil, ErrAccessTokenNameDuplicate } // a token-authenticated request must not mint a token with a broader scope than its own, nor - // drop the public-only restriction. Web routes accept basic-auth PATs/OAuth tokens too, so this - // must mirror the REST API guard in routers/api/v1/user/app.go. - apiTokenScope, hasApiTokenScope := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) - if hasApiTokenScope { + // drop the public-only restriction; mirrors the REST API guard in routers/api/v1/user/app.go + // for the day a token-auth path reaches here + if apiTokenScope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope); ok { hasScope, err := apiTokenScope.CanCreateChildScope(t.Scope) if err != nil { - ctx.ServerError("CanCreateChildScope", err) - return + return nil, err } if !hasScope { - ctx.HTTPError(http.StatusForbidden, "cannot create an access token with a broader scope than the authenticating token") - return + return nil, ErrAccessTokenScopeEscalation } if t.Scope, err = t.Scope.EnforcePublicOnlyFrom(apiTokenScope); err != nil { - ctx.ServerError("EnforcePublicOnlyFrom", err) - return + return nil, err } } if err := auth_model.NewAccessToken(ctx, t); err != nil { - ctx.ServerError("NewAccessToken", err) + return nil, err + } + return t, nil +} + +// ApplicationsPost response for add user's access token +func ApplicationsPost(ctx *context.Context) { + form := web.GetForm[*forms.NewAccessTokenForm](ctx) + ctx.Data["Title"] = ctx.Tr("settings_title") + ctx.Data["PageIsSettingsApplications"] = true + + if ctx.HasError() { + loadApplicationsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + } + + // a non-admin may still hold an admin-scoped token: it stays inert until they become one + t, err := NewAccessTokenFromForm(ctx, ctx.Doer, form.Name, true) + switch { + case errors.Is(err, ErrAccessTokenNoPermission): + ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true) + loadApplicationsData(ctx) + ctx.HTML(http.StatusOK, tplSettingsApplications) + return + case errors.Is(err, ErrAccessTokenNameDuplicate): + ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", form.Name)) + ctx.Redirect(setting.AppSubURL + "/user/settings/applications") + return + case errors.Is(err, ErrAccessTokenScopeEscalation): + ctx.HTTPError(http.StatusForbidden, err.Error()) + return + case err != nil: + ctx.ServerError("NewAccessTokenFromForm", err) return } diff --git a/routers/web/web.go b/routers/web/web.go index 861df9d866a5a..5e3ef04ea4046 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -815,9 +815,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/{userid}", admin.ViewUser) m.Combo("/{userid}/edit").Get(admin.EditUser).Post(web.Bind[*forms.AdminEditUserForm](), admin.EditUserPost) m.Post("/{userid}/impersonate", admin.ImpersonateUser) + m.Post("/{userid}/convert_type", admin.ConvertUserType) m.Post("/{userid}/delete", admin.DeleteUser) m.Post("/{userid}/avatar", web.Bind[*forms.AvatarForm](), admin.AvatarPost) m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) + m.Post("/{userid}/access_tokens", web.Bind[*forms.NewAccessTokenForm](), admin.NewBotTokenPost) + m.Post("/{userid}/access_tokens/delete", admin.DeleteBotToken) }) m.Group("/badges", func() { diff --git a/services/auth/reverseproxy.go b/services/auth/reverseproxy.go index 8a1c3535404b4..9193725ac5cca 100644 --- a/services/auth/reverseproxy.go +++ b/services/auth/reverseproxy.go @@ -64,6 +64,11 @@ func (r *ReverseProxy) getUserFromAuthUser(req *http.Request) (*user_model.User, return nil, err } user = r.newUser(req) + } else if !user.IsIndividual() { + // only individual users may sign in; bot/organization accounts must not + // be authenticated through reverse proxy headers + log.Trace("ReverseProxy Authorization: user %q is not an individual, ignoring", username) + return nil, nil //nolint:nilnil // the auth method is not applicable } return user, nil } @@ -98,6 +103,12 @@ func (r *ReverseProxy) getUserFromAuthEmail(req *http.Request) *user_model.User } return nil } + if !user.IsIndividual() { + // only individual users may sign in; bot/organization accounts must not + // be authenticated through reverse proxy headers + log.Trace("ReverseProxy Authorization: user with email %q is not an individual, ignoring", email) + return nil + } return user } diff --git a/services/auth/reverseproxy_test.go b/services/auth/reverseproxy_test.go index 0602d295b37c4..b8ea45f76957c 100644 --- a/services/auth/reverseproxy_test.go +++ b/services/auth/reverseproxy_test.go @@ -4,6 +4,7 @@ package auth import ( + "net/http" "testing" "gitea.dev/models/unittest" @@ -17,6 +18,37 @@ import ( "github.com/stretchr/testify/require" ) +func TestReverseProxyAuth_BotIgnored(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + bot := &user_model.User{ + Name: "rp-bot", + Email: "rp-bot@example.com", + Type: user_model.UserTypeBot, + MustChangePassword: false, + IsActive: true, + } + require.NoError(t, user_model.AdminCreateUser(t.Context(), bot, &user_model.Meta{})) + + defer test.MockVariableValue(&setting.Service.EnableReverseProxyEmail, true)() + + rp := &ReverseProxy{} + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + // resolving a bot by reverse-proxy username header must yield no user + req.Header.Set(setting.ReverseProxyAuthUser, "rp-bot") + u, err := rp.getUserFromAuthUser(req) + assert.NoError(t, err) + assert.Nil(t, u) + + // resolving a bot by reverse-proxy email header must yield no user + req.Header.Del(setting.ReverseProxyAuthUser) + req.Header.Set(setting.ReverseProxyAuthEmail, "rp-bot@example.com") + assert.Nil(t, rp.getUserFromAuthEmail(req)) +} + func TestReverseProxyLastLogin(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")() diff --git a/services/auth/session.go b/services/auth/session.go index 1a863d88f8c9b..9af12cc492603 100644 --- a/services/auth/session.go +++ b/services/auth/session.go @@ -50,6 +50,13 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto return nil, nil //nolint:nilnil // the auth method is not applicable } + // a session opened before a conversion to bot must not survive it, and sessions cannot be + // enumerated per user, so it is rejected here instead and the caller drops it + if !user.IsIndividual() { + log.Trace("Session Authorization: user %-v is not an individual, ignoring the session", user) + return nil, nil //nolint:nilnil // the auth method is not applicable + } + log.Trace("Session Authorization: Logged in user %-v", user) return user, nil } diff --git a/services/auth/session_test.go b/services/auth/session_test.go new file mode 100644 index 0000000000000..a8753c0fb1587 --- /dev/null +++ b/services/auth/session_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "net/http" + "testing" + + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + "gitea.dev/modules/session" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSessionVerify(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + req, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + sess := session.NewMockMemStore("dummy-sid") + method := &Session{} + + // an individual keeps its session + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + require.NoError(t, sess.Set(session.KeyUID, user.ID)) + u, err := method.Verify(req, nil, nil, sess) + assert.NoError(t, err) + require.NotNil(t, u) + assert.Equal(t, user.ID, u.ID) + + // a session that outlived the conversion of its user into a bot is not accepted anymore + require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: user.ID, Type: user_model.UserTypeBot}, "type")) + u, err = method.Verify(req, nil, nil, sess) + assert.NoError(t, err) + assert.Nil(t, u) +} diff --git a/services/auth/signin.go b/services/auth/signin.go index 516d5463bc492..bcf1697aef6bd 100644 --- a/services/auth/signin.go +++ b/services/auth/signin.go @@ -122,10 +122,16 @@ func UserSignIn(ctx context.Context, username, password string) (*user_model.Use authUser, err := authenticator.Authenticate(ctx, nil, username, password) if err == nil { - if !authUser.ProhibitLogin { + switch { + case !authUser.IsIndividual(): + // only individual users may sign in interactively; an external source + // must not return a bot/organization account for a login session + err = user_model.ErrUserNotExist{Name: username} + case authUser.ProhibitLogin: + err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name} + default: return authUser, source, nil } - err = user_model.ErrUserProhibitLogin{UID: authUser.ID, Name: authUser.Name} } if user_model.IsErrUserNotExist(err) { diff --git a/services/auth/signin_test.go b/services/auth/signin_test.go new file mode 100644 index 0000000000000..51726948dafc6 --- /dev/null +++ b/services/auth/signin_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "testing" + + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" + "gitea.dev/models/unittest" + user_model "gitea.dev/models/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockBotSource reproduces the behaviour of external sources (e.g. LDAP) that +// resolve an already existing local user by name without checking its type. +type mockBotSource struct { + auth_model.ConfigBase +} + +func (s *mockBotSource) FromDB(bs []byte) error { return nil } +func (s *mockBotSource) ToDB() ([]byte, error) { return []byte("{}"), nil } + +func (s *mockBotSource) Authenticate(ctx context.Context, _ *user_model.User, login, _ string) (*user_model.User, error) { + return user_model.GetUserByName(ctx, login) +} + +// mockBotSourceType is a test-only auth source type, kept out of the real enum range. +const mockBotSourceType auth_model.Type = 100 + +func init() { + auth_model.RegisterTypeConfig(mockBotSourceType, &mockBotSource{}) +} + +func TestUserSignIn_BotCannotSignIn(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + bot := &user_model.User{ + Name: "test-bot", + Email: "test-bot@example.com", + Type: user_model.UserTypeBot, + MustChangePassword: false, + IsActive: true, + } + require.NoError(t, user_model.AdminCreateUser(t.Context(), bot, &user_model.Meta{})) + + // register an active external source that would otherwise hand back the bot user + require.NoError(t, db.Insert(t.Context(), &auth_model.Source{ + Type: mockBotSourceType, + Name: "mock-bot-source", + IsActive: true, + Cfg: &mockBotSource{}, + })) + + // a bot has no password and must not be able to sign in interactively, neither + // via the local source nor via the external source fallback loop + _, _, err := UserSignIn(t.Context(), "test-bot", "") + assert.ErrorAs(t, err, &user_model.ErrUserNotExist{}) +} diff --git a/services/auth/sspi.go b/services/auth/sspi.go index 9aa43159dea53..009f4636056d7 100644 --- a/services/auth/sspi.go +++ b/services/auth/sspi.go @@ -117,6 +117,11 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore, log.Error("CreateUser: %v", err) return nil, err } + } else if !user.IsIndividual() { + // only individual users may sign in; a bot/organization account whose name + // matches a domain account must not be authenticated + log.Trace("SSPI Authorization: user %q is not an individual, ignoring", username) + return nil, nil //nolint:nilnil // the auth method is not applicable } if s.CreateSession { diff --git a/services/forms/admin.go b/services/forms/admin.go index 067cced6ef029..14dc1559c5e06 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -14,6 +14,7 @@ type AdminCreateUserForm struct { LoginType string `binding:"Required"` LoginName string UserName string `binding:"Required;Username;MaxSize(40)"` + UserType string `binding:"In(,individual,bot)"` Email string `binding:"Required;Email;MaxSize(254)"` Password string `binding:"MaxSize(255)"` SendNotify bool @@ -39,7 +40,7 @@ type AdminEditBadgeForm struct { // AdminEditUserForm form for admin to create user type AdminEditUserForm struct { middleware.FormDefaultValidator - LoginType string `binding:"Required"` + LoginType string // empty for bot users: they have no auth source to edit UserName string `binding:"Username;MaxSize(40)"` LoginName string FullName string `binding:"MaxSize(100)"` diff --git a/services/forms/admin_test.go b/services/forms/admin_test.go new file mode 100644 index 0000000000000..f366d1d628137 --- /dev/null +++ b/services/forms/admin_test.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forms + +import ( + "testing" + + "gitea.dev/modules/validation" + + "github.com/stretchr/testify/assert" +) + +func TestAdminCreateUserFormUserType(t *testing.T) { + for _, userType := range []string{"", "individual", "bot"} { + form := &AdminCreateUserForm{ + LoginType: "local", + UserName: "user", + UserType: userType, + Email: "user@example.com", + } + assert.Empty(t, validation.Binder().Validate(t.Context(), form)) + } + + form := &AdminCreateUserForm{ + LoginType: "local", + UserName: "user", + UserType: "invalid", + Email: "user@example.com", + } + assert.NotEmpty(t, validation.Binder().Validate(t.Context(), form)) +} diff --git a/services/forms/user_form.go b/services/forms/user_form.go index bcf28db817347..e82d73a35df7b 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -6,8 +6,10 @@ package forms import ( "mime/multipart" + "net/url" "strings" + auth_model "gitea.dev/models/auth" user_model "gitea.dev/models/user" "gitea.dev/modules/setting" "gitea.dev/modules/structs" @@ -254,6 +256,18 @@ type NewAccessTokenForm struct { Name string `binding:"Required;MaxSize(255)" locale:"settings.token_name"` } +// AccessTokenScopeFromForm collects all "scope-*" values of a submitted token form +// and joins them into Gitea's comma-separated AccessTokenScope format. +func AccessTokenScopeFromForm(form url.Values) auth_model.AccessTokenScope { + var scopeNames []string + for k, v := range form { + if strings.HasPrefix(k, "scope-") { + scopeNames = append(scopeNames, v...) + } + } + return auth_model.AccessTokenScope(strings.Join(scopeNames, ",")) +} + // EditOAuth2ApplicationForm form for editing oauth2 applications type EditOAuth2ApplicationForm struct { Name string `binding:"Required;MaxSize(255)" form:"application_name"` diff --git a/services/user/update.go b/services/user/update.go index 441e021774917..da305a2950b15 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -7,12 +7,15 @@ import ( "context" "fmt" + activities_model "gitea.dev/models/activities" auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" user_model "gitea.dev/models/user" password_module "gitea.dev/modules/auth/password" "gitea.dev/modules/optional" "gitea.dev/modules/setting" "gitea.dev/modules/structs" + "gitea.dev/modules/util" ) type UpdateOptionField[T any] struct { @@ -132,6 +135,9 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er } if opts.IsAdmin.Has() { if opts.IsAdmin.Value().FieldValue /* true */ { + if u.IsTypeBot() { + return user_model.ErrBotCanNotBeAdmin + } u.IsAdmin = opts.IsAdmin.Value().FieldValue // set IsAdmin=true cols = append(cols, "is_admin") } else if !user_model.IsLastAdminUser(ctx, u) /* not the last admin */ { @@ -195,17 +201,28 @@ type UpdateAuthOptions struct { } func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error { - if opts.LoginSource.Has() { - source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value()) - if err != nil { - return err + if u.IsTypeBot() { + // a bot only ever authenticates with an access token, so it has neither a password nor an auth source + if opts.Password.Has() { + return fmt.Errorf("%w: a bot account cannot have a password", util.ErrInvalidArgument) } + if opts.LoginSource.ValueOrDefault(0) != 0 || opts.LoginName.ValueOrDefault("") != "" { + return fmt.Errorf("%w: a bot account cannot be linked to an authentication source", util.ErrInvalidArgument) + } + u.LoginType, u.LoginSource, u.LoginName = auth_model.Plain, 0, "" + } else { + if opts.LoginSource.Has() { + source, err := auth_model.GetSourceByID(ctx, opts.LoginSource.Value()) + if err != nil { + return err + } - u.LoginType = source.Type - u.LoginSource = source.ID - } - if opts.LoginName.Has() { - u.LoginName = opts.LoginName.Value() + u.LoginType = source.Type + u.LoginSource = source.ID + } + if opts.LoginName.Has() { + u.LoginName = opts.LoginName.Value() + } } deleteAuthTokens := false @@ -245,3 +262,83 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions } return nil } + +// CheckConvertUserType checks whether the account type of the given user can be converted +func CheckConvertUserType(u *user_model.User) error { + switch { + case u.IsAdmin: + // automation does not need site-wide root access, so the admin permission has to be + // dropped deliberately before the account can become a bot + return user_model.ErrBotCanNotBeAdmin + case !u.IsIndividual() && !u.IsTypeBot(): + return user_model.ErrUserTypeCanNotConvert + } + return nil +} + +// ConvertUserType converts a user between the individual and bot types. +// When converting to a bot the user becomes a local, non-interactive account: +// its password and auth source are cleared so it can only be used with access tokens. +func ConvertUserType(ctx context.Context, u *user_model.User, targetType user_model.UserType) error { + if u.Type == targetType { + return nil + } + if targetType != user_model.UserTypeIndividual && targetType != user_model.UserTypeBot { + return user_model.ErrUserTypeCanNotConvert + } + if err := CheckConvertUserType(u); err != nil { + return err + } + + updatedUser := *u + updatedUser.Type = targetType + cols := []string{"type"} + + if targetType == user_model.UserTypeBot { + // see models/user/bot_user_design.md for the fate of every credential and auth artifact + updatedUser.Passwd = "" + updatedUser.PasswdHashAlgo = "" + updatedUser.Salt = "" + updatedUser.MustChangePassword = false + updatedUser.LoginType = auth_model.Plain + updatedUser.LoginSource = 0 + updatedUser.LoginName = "" + cols = append(cols, "passwd", "passwd_hash_algo", "salt", "must_change_password", "login_type", "login_source", "login_name") + + // atomic, so a mid-sequence failure cannot leave a half-converted account + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := user_model.UpdateUserCols(ctx, &updatedUser, cols...); err != nil { + return err + } + // revoke persisted sign-in sessions so the former individual cannot stay logged in + if err := auth_model.DeleteAuthTokensByUserID(ctx, updatedUser.ID); err != nil { + return err + } + // remove OAuth2 applications and grants owned/authorized by the account + if err := auth_model.DeleteOAuth2RelictsByUserID(ctx, updatedUser.ID); err != nil { + return err + } + // TOTP and WebAuthn only guard an interactive sign-in, which a bot no longer has + if _, _, err := auth_model.DisableTwoFactor(ctx, updatedUser.ID); err != nil { + return err + } + // a bot has no inbox, so drop the notifications it accumulated as an individual + if err := db.DeleteBeans(ctx, &activities_model.Notification{UserID: updatedUser.ID}); err != nil { + return err + } + // an OpenID URI is an external identity that can sign the account in, so it goes too + if err := db.DeleteBeans(ctx, &user_model.UserOpenID{UID: updatedUser.ID}); err != nil { + return err + } + // the account is now local, so drop any external (OAuth2/LDAP/...) login links + return user_model.RemoveAllAccountLinks(ctx, &updatedUser) + }); err != nil { + return err + } + } else if err := user_model.UpdateUserCols(ctx, &updatedUser, cols...); err != nil { + return err + } + + *u = updatedUser + return nil +} diff --git a/services/user/update_test.go b/services/user/update_test.go index 8ef59a99c020c..fa8464891325a 100644 --- a/services/user/update_test.go +++ b/services/user/update_test.go @@ -4,8 +4,12 @@ package user import ( + "context" "testing" + activities_model "gitea.dev/models/activities" + auth_model "gitea.dev/models/auth" + "gitea.dev/models/db" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" password_module "gitea.dev/modules/auth/password" @@ -13,6 +17,7 @@ import ( "gitea.dev/modules/setting" "gitea.dev/modules/structs" "gitea.dev/modules/test" + "gitea.dev/modules/util" "github.com/stretchr/testify/assert" ) @@ -153,3 +158,104 @@ func TestUpdateUserVisibility(t *testing.T) { Visibility: optional.Some(structs.VisibleTypePublic), })) } + +func TestConvertUserType(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // user2 is a local individual that owns an OAuth2 application and an access token + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.True(t, user.IsIndividual()) + assert.NotEmpty(t, user.Passwd) + assert.Positive(t, unittest.GetCount(t, &auth_model.OAuth2Application{UID: user.ID})) + tokensBefore := unittest.GetCount(t, &auth_model.AccessToken{UID: user.ID}) + assert.Positive(t, tokensBefore) + assert.Positive(t, unittest.GetCount(t, &activities_model.Notification{UserID: user.ID})) + assert.Positive(t, unittest.GetCount(t, &user_model.UserOpenID{UID: user.ID})) + assert.NoError(t, db.Insert(t.Context(), &auth_model.TwoFactor{UID: user.ID})) + assert.NoError(t, db.Insert(t.Context(), &auth_model.WebAuthnCredential{UserID: user.ID, Name: "key"})) + + // individual -> bot: credentials, auth source and interactive-auth artifacts are cleared + assert.NoError(t, ConvertUserType(t.Context(), user, user_model.UserTypeBot)) + assert.True(t, user.IsTypeBot()) + + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.Equal(t, user_model.UserTypeBot, user.Type) + assert.Empty(t, user.Passwd) + assert.Empty(t, user.Salt) + assert.Empty(t, user.PasswdHashAlgo) + assert.False(t, user.MustChangePassword) + assert.EqualValues(t, 0, user.LoginSource) + // OAuth2 applications/grants are removed, but access tokens are kept + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.OAuth2Application{UID: user.ID})) + assert.Equal(t, tokensBefore, unittest.GetCount(t, &auth_model.AccessToken{UID: user.ID})) + assert.Equal(t, 0, unittest.GetCount(t, &activities_model.Notification{UserID: user.ID})) + assert.Equal(t, 0, unittest.GetCount(t, &user_model.UserOpenID{UID: user.ID})) + // a second factor only guards an interactive sign-in, which the account no longer has + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.TwoFactor{UID: user.ID})) + assert.Equal(t, 0, unittest.GetCount(t, &auth_model.WebAuthnCredential{UserID: user.ID})) + + // a bot has no interactive login, so a password or auth source is rejected rather than ignored + assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{Password: optional.Some("%$DRZUVB576tfzgu")}), util.ErrInvalidArgument) + assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{LoginSource: optional.Some(int64(1))}), util.ErrInvalidArgument) + assert.ErrorIs(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{LoginName: optional.Some("cn=bot")}), util.ErrInvalidArgument) + assert.Empty(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).Passwd) + + // an unrelated auth update keeps the bot a local account + assert.NoError(t, UpdateAuth(t.Context(), user, &UpdateAuthOptions{ProhibitLogin: optional.Some(true)})) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.Equal(t, auth_model.Plain, user.LoginType) + assert.Empty(t, user.LoginName) + + // bot -> individual + assert.NoError(t, ConvertUserType(t.Context(), user, user_model.UserTypeIndividual)) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.Equal(t, user_model.UserTypeIndividual, user.Type) + + // organizations cannot be converted + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) + assert.True(t, org.IsOrganization()) + assert.Error(t, ConvertUserType(t.Context(), org, user_model.UserTypeBot)) + + // a site administrator must drop the admin permission before becoming a bot + admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + assert.True(t, admin.IsAdmin) + err := ConvertUserType(t.Context(), admin, user_model.UserTypeBot) + assert.ErrorIs(t, err, user_model.ErrBotCanNotBeAdmin) + admin = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + assert.Equal(t, user_model.UserTypeIndividual, admin.Type) +} + +func TestConvertUserTypeDoesNotMutateUserOnError(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + originalUser := *user + // unit tests always run on SQLite (models/unittest/testdb.go), so a trigger is the cheapest mid-transaction failure + _, err := db.GetEngine(t.Context()).Exec(`CREATE TRIGGER fail_notification_delete + BEFORE DELETE ON notification WHEN OLD.user_id = 2 + BEGIN SELECT RAISE(FAIL, 'forced notification delete failure'); END`) + if !assert.NoError(t, err) { + return + } + t.Cleanup(func() { + _, err := db.GetEngine(context.Background()).Exec("DROP TRIGGER fail_notification_delete") + assert.NoError(t, err) + }) + + assert.Error(t, ConvertUserType(t.Context(), user, user_model.UserTypeBot)) + assert.Equal(t, originalUser, *user) + persistedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID}) + assert.Equal(t, user_model.UserTypeIndividual, persistedUser.Type) +} + +func TestUpdateUserBotCannotBecomeAdmin(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.NoError(t, ConvertUserType(t.Context(), user, user_model.UserTypeBot)) + + err := UpdateUser(t.Context(), user, &UpdateOptions{IsAdmin: UpdateOptionFieldFromValue(true)}) + assert.ErrorIs(t, err, user_model.ErrBotCanNotBeAdmin) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + assert.False(t, user.IsAdmin) +} diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl index 0563cf63b4d44..cad1babc59131 100644 --- a/templates/admin/user/edit.tmpl +++ b/templates/admin/user/edit.tmpl @@ -1,4 +1,5 @@ {{template "admin/layout_head" (dict "pageClass" "admin edit user")}} + {{$convertTo := Iif .User.IsTypeBot "individual" "bot"}}
{{ctx.Locale.Tr "admin.users.password_helper"}}
-{{ctx.Locale.Tr "admin.users.password_helper"}}
+