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
+91 -17
View File
@@ -22,6 +22,7 @@ import (
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/templates"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
@@ -30,6 +31,7 @@ import (
"gitea.dev/services/audit"
auth_service "gitea.dev/services/auth"
"gitea.dev/services/context"
"gitea.dev/services/convert"
"gitea.dev/services/forms"
"gitea.dev/services/mailer"
org_service "gitea.dev/services/org"
@@ -60,6 +62,15 @@ func Users(ctx *context.Context) {
}
sortType := ctx.FormString("sort", UserSearchDefaultAdminSort)
// unfiltered, an administrator needs to list every account kind
types := []user_model.UserType{user_model.UserTypeIndividual, user_model.UserTypeUserReserved, user_model.UserTypeBot, user_model.UserTypeRemoteUser}
userTypeFilter := api.UserTypeString(ctx.FormString("user_type"))
ctx.Data["UserTypeFilter"] = ""
if t, err := convert.UserTypeFromString(userTypeFilter); err == nil {
types, ctx.Data["UserTypeFilter"] = []user_model.UserType{t}, userTypeFilter
}
ctx.PageData["adminUserListSearchForm"] = map[string]any{
"StatusFilterMap": statusFilterMap,
"SortType": sortType,
@@ -67,7 +78,7 @@ func Users(ctx *context.Context) {
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,
},
@@ -77,7 +88,6 @@ 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),
}, tplUsers)
}
@@ -90,6 +100,7 @@ func NewUser(ctx *context.Context) {
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["login_type"] = "0-0"
ctx.Data["user_type"] = api.UserTypeStringUser
sources, err := db.Find[auth.Source](ctx, auth.FindSourcesOptions{
IsActive: optional.Some(true),
@@ -140,7 +151,10 @@ func NewUserPost(ctx *context.Context) {
Visibility: &form.Visibility,
}
if len(form.LoginType) > 0 {
if form.UserType == api.UserTypeStringBot {
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)
@@ -149,7 +163,7 @@ func NewUserPost(ctx *context.Context) {
u.LoginName = form.LoginName
}
}
if u.LoginType == auth.NoType || u.LoginType == auth.Plain {
if !u.IsTypeBot() && (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)
@@ -260,6 +274,7 @@ func prepareUserInfo(ctx *context.Context) *user_model.User {
return nil
}
ctx.Data["TwoFactorEnabled"] = hasTOTP || hasWebAuthn
ctx.Data["CanConvertUserType"] = user_service.CheckConvertUserType(u) == nil
return u
}
@@ -305,9 +320,42 @@ func ViewUser(ctx *context.Context) {
return
}
if u.IsTypeBot() {
ctx.Data["BotAccessTokens"] = user_setting.NewAccessTokensPanel(ctx, u, ctx.Link+"/access_tokens")
if ctx.Written() {
return
}
}
ctx.HTML(http.StatusOK, tplUserView)
}
// getTargetBot loads the bot whose tokens an admin manages, other accounts manage their own
func getTargetBot(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
}
if !u.IsTypeBot() {
ctx.JSONError(ctx.Tr("admin.users.bot_token_only"))
return nil
}
return u
}
func NewBotTokenPost(ctx *context.Context) {
if u := getTargetBot(ctx); u != nil {
user_setting.CreateAccessToken(ctx, u)
}
}
func DeleteBotToken(ctx *context.Context) {
if u := getTargetBot(ctx); u != nil {
user_setting.DeleteAccessToken(ctx, u)
}
}
func editUserCommon(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.users.edit_account")
ctx.Data["PageIsAdminUsers"] = true
@@ -344,6 +392,8 @@ func EditUserPost(ctx *context.Context) {
return
}
userLink := setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid"))
if form.UserName != "" {
if err := user_service.RenameUser(ctx, u, form.UserName, ctx.Doer); err != nil {
switch {
@@ -369,9 +419,19 @@ func EditUserPost(ctx *context.Context) {
}
}
authOpts := &user_service.UpdateAuthOptions{
Password: optional.FromNonDefault(form.Password),
LoginName: optional.Some(form.LoginName),
userType := u.Type
if formUserType, err := convert.UserTypeFromString(form.UserType); err == nil {
userType = formUserType
}
authOpts := &user_service.UpdateAuthOptions{}
if !u.IsTypeBot() && userType != user_model.UserTypeBot { // the auth fields hidden for bots still submit their values
authOpts.Password = optional.FromNonDefault(form.Password)
authOpts.LoginName = optional.Some(form.LoginName)
if fields := strings.Split(form.LoginType, "-"); len(fields) == 2 {
authSource, _ := strconv.ParseInt(fields[1], 10, 64)
authOpts.LoginSource = optional.Some(authSource)
}
}
// skip self Prohibit Login
@@ -381,13 +441,6 @@ func EditUserPost(ctx *context.Context) {
authOpts.ProhibitLogin = optional.Some(form.ProhibitLogin)
}
fields := strings.Split(form.LoginType, "-")
if len(fields) == 2 {
authSource, _ := strconv.ParseInt(fields[1], 10, 64)
authOpts.LoginSource = optional.Some(authSource)
}
if err := user_service.UpdateAuth(ctx, u, authOpts); err != nil {
switch {
case errors.Is(err, password.ErrMinLength):
@@ -402,6 +455,9 @@ 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.Flash.Error(err.Error())
ctx.Redirect(userLink)
default:
ctx.ServerError("UpdateUser", err)
}
@@ -440,16 +496,28 @@ func EditUserPost(ctx *context.Context) {
IsRestricted: optional.Some(form.Restricted),
Visibility: optional.Some(form.Visibility),
Language: optional.Some(form.Language),
UserType: optional.Some(userType),
}
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.convert_type.admin_not_allowed"))
ctx.Redirect(userLink)
case errors.Is(err, user_model.ErrUserTypeCanNotConvert):
ctx.Flash.Error(ctx.Tr("admin.users.convert_type.not_convertible"))
ctx.Redirect(userLink)
case errors.Is(err, util.ErrInvalidArgument):
ctx.Flash.Error(err.Error())
ctx.Redirect(userLink)
default:
ctx.ServerError("UpdateUser", err)
}
return
}
log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, u.Name)
if form.Reset2FA {
@@ -460,7 +528,7 @@ func EditUserPost(ctx *context.Context) {
}
ctx.Flash.Success(ctx.Tr("admin.users.update_profile_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/users/" + url.PathEscape(ctx.PathParam("userid")))
ctx.Redirect(userLink)
}
func ImpersonateUser(ctx *context.Context) {
@@ -469,6 +537,12 @@ func ImpersonateUser(ctx *context.Context) {
ctx.JSONError("unable to get user")
return
}
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)