feat: manage bot accounts from the admin UI, API and CLI (#38966)

Adds first-class bot accounts (`UserTypeBot`): local, password-less
users for automation that authenticate only with access tokens.

1. Admin UI: create bots, filter users by type, manage a bot's access
tokens, convert between user and bot
2. API: `POST /admin/users/{username}/convert-type`, and user objects
gain a GitHub-compatible `type` (`User`, `Organization`, `Bot`)
3. CLI: `gitea admin user change-type`, `--user-type` accepts `User` or
`Bot` case-insensitively
4. Converting keeps the password, 2FA, OAuth2 grants and access tokens,
and since sign-in rejects bots, converting back restores the account.
Only local, non-admin accounts can be converted, and conversions are
audited
5. Session, reverse proxy, SSPI, external source and password reset
sign-in reject non-individual users, so a bot never gets an interactive
session
6. Bots receive no notifications or emails

Co-authored-by: Nicolas <bircni@icloud.com>
Co-authored-by: joestump <joe@joestump.net>
Co-authored-by: Joe Stump <joe@stu.mp>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Joe (Agent) Stump
2026-09-18 12:43:36 +00:00
committed by GitHub
co-authored by Nicolas joestump Joe Stump silverwind Lunny Xiao
parent db7dbd5a6b
commit 3bec08f998
71 changed files with 1246 additions and 330 deletions
+17
View File
@@ -5,10 +5,26 @@ package cmd
import (
"context"
"strings"
user_model "gitea.dev/models/user"
"gitea.dev/modules/util"
"github.com/urfave/cli/v3"
)
// parseUserTypeFlag parses "--user-type", keeping the lowercase "individual" and "bot" the flag shipped with
func parseUserTypeFlag(s string) (user_model.UserType, error) {
switch strings.ToLower(s) {
case "user", "individual":
return user_model.UserTypeIndividual, nil
case "bot":
return user_model.UserTypeBot, nil
default:
return 0, util.NewInvalidArgumentErrorf("invalid user type %q (expected User, Bot)", s)
}
}
func newUserCommand() *cli.Command {
return &cli.Command{
Name: "user",
@@ -24,6 +40,7 @@ func newUserCommand() *cli.Command {
newUserGenerateAccessTokenCommand(),
microcmdUserMustChangePassword(),
microcmdUserDisableTwoFactor(),
microcmdUserChangeType(),
},
}
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
user_model "gitea.dev/models/user"
"gitea.dev/modules/optional"
"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 an account between the user 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: User or Bot",
Required: true,
},
},
}
}
func runChangeUserType(ctx context.Context, c *cli.Command) error {
targetType, err := parseUserTypeFlag(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.UpdateUser(ctx, user, &user_service.UpdateOptions{UserType: optional.Some(targetType)}); err != nil {
return err
}
cprintf(c, "%s's type has been successfully changed to %s!\n", user.Name, targetType.DisplayName())
return nil
}
+8 -12
View File
@@ -44,8 +44,8 @@ func microcmdUserCreate() *cli.Command {
Flags: []cli.Flag{
&cli.StringFlag{
Name: "user-type",
Usage: "Set user's type: individual or bot",
Value: "individual",
Usage: "Set user's type: User or Bot",
Value: "User",
},
&cli.StringFlag{
Name: "password",
@@ -105,20 +105,16 @@ 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 := parseUserTypeFlag(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.
// Some other commands like "change-password" also only support regular user accounts.
// It needs to clarify the "password" behavior for bot users in the future.
// At the moment, we do not allow setting password for bot users.
if c.IsSet("password") || c.IsSet("random-password") {
return errors.New("password can only be set for individual users")
return errors.New("password can only be set for user accounts")
}
}
@@ -162,7 +158,7 @@ func runCreateUser(ctx context.Context, c *cli.Command) error {
mustChangePassword := userType == user_model.UserTypeIndividual
if c.IsSet("must-change-password") {
if userType != user_model.UserTypeIndividual {
return errors.New("must-change-password flag can only be set for individual users")
return errors.New("must-change-password flag can only be set for user accounts")
}
// if the flag is set, use the value provided by the user
mustChangePassword = c.Bool("must-change-password")
+9 -2
View File
@@ -56,14 +56,21 @@ func TestAdminUserCreate(t *testing.T) {
t.Run("UserType", func(t *testing.T) {
reset()
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", "--password", "123"), "can only be set for user accounts")
assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--must-change-password"), "can only be set for user accounts")
assert.ErrorContains(t, createUser("u", "--user-type", "bot", "--admin"), "bot user can not be a site administrator")
assert.NoError(t, createUser("u", "--user-type", "bot"))
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"})
assert.Equal(t, user_model.UserTypeBot, u.Type)
assert.Empty(t, u.Passwd)
assert.False(t, u.MustChangePassword, "bot users should not be forced to change password")
changeType := func(userType string) error {
return microcmdUserChangeType().Run(t.Context(), []string{"change-type", "--username", "u", "--user-type", userType})
}
assert.NoError(t, changeType("User"))
assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"}).IsIndividual())
})
t.Run("AccessToken", func(t *testing.T) {