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
+45 -2
View File
@@ -206,7 +206,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
}
@@ -237,7 +237,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
}
@@ -552,3 +552,46 @@ func RenameUser(ctx *context.APIContext) {
}
ctx.Status(http.StatusNoContent)
}
// ConvertUserType converts an account between the user and bot types
func ConvertUserType(ctx *context.APIContext) {
// swagger:operation POST /admin/users/{username}/convert-type admin adminConvertUserType
// ---
// summary: Convert an account between the user 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"
targetType, err := convert.UserTypeFromString(web.GetForm[*api.ConvertUserTypeOption](ctx).UserType)
if err != nil {
ctx.APIErrorAuto(err)
return
}
if err := user_service.UpdateUser(ctx, ctx.ContextUser, &user_service.UpdateOptions{UserType: optional.Some(targetType)}); err != nil {
ctx.APIErrorAuto(err)
return
}
ctx.Status(http.StatusNoContent)
}
+1
View File
@@ -1905,6 +1905,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)
+3
View File
@@ -59,6 +59,9 @@ type swaggerParameterBodies struct {
// in:body
RenameUserOption api.RenameUserOption
// in:body
ConvertUserTypeOption api.ConvertUserTypeOption
// in:body
CreateLabelOption api.CreateLabelOption
// in:body
+2 -3
View File
@@ -25,9 +25,8 @@ func Organizations(ctx *context.Context) {
sortOrder := ctx.FormString("sort", UserSearchDefaultAdminSort)
explore.RenderUserSearch(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization},
IncludeReserved: true, // administrator needs to list all accounts include reserved
Actor: ctx.Doer,
Types: []user_model.UserType{user_model.UserTypeOrganization, user_model.UserTypeOrganizationReserved},
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.OrgPagingNum,
},
+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)
+3
View File
@@ -62,6 +62,9 @@ func ForgotPasswdPost(ctx *context.Context) {
ctx.Data["Email"] = email
u, err := user_model.GetUserByEmail(ctx, email)
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)
+63 -35
View File
@@ -11,10 +11,10 @@ import (
audit_model "gitea.dev/models/audit"
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"
"gitea.dev/modules/web"
"gitea.dev/services/audit"
"gitea.dev/services/context"
"gitea.dev/services/forms"
@@ -22,6 +22,7 @@ import (
const (
tplSettingsApplications templates.TplName = "user/settings/applications"
tplAccessTokens templates.TplName = "shared/user/access_tokens"
)
// Applications render manage access token page
@@ -34,11 +35,40 @@ 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
type AccessTokensPanel struct {
Tokens []*auth_model.AccessToken
ScopeCategories []string
ScopePublicOnly auth_model.AccessTokenScope
Link string
IsBot bool
NewTokenValue string
}
func NewAccessTokensPanel(ctx *context.Context, owner *user_model.User, link string) *AccessTokensPanel {
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: owner.ID})
if err != nil {
ctx.ServerError("ListAccessTokens", err)
return nil
}
panel := &AccessTokensPanel{
Tokens: tokens,
ScopeCategories: auth_model.GetAccessTokenCategories(),
ScopePublicOnly: auth_model.AccessTokenScopePublicOnly,
Link: link,
IsBot: owner.IsTypeBot(),
}
if !owner.IsAdmin {
panel.ScopeCategories = util.SliceRemoveAll(panel.ScopeCategories, "admin")
}
return panel
}
// CreateAccessToken handles the panel's create form, which posts to the panel link
func CreateAccessToken(ctx *context.Context, owner *user_model.User) {
form := context.GetFetchActionForm[*forms.NewAccessTokenForm](ctx)
if form == nil {
return
}
_ = ctx.Req.ParseForm()
var scopeNames []string
@@ -55,17 +85,12 @@ func ApplicationsPost(ctx *context.Context) {
return
}
if !scope.HasPermissionScope() {
ctx.Flash.Error(ctx.Tr("settings.at_least_one_permission"), true)
}
if ctx.HasError() {
loadApplicationsData(ctx)
ctx.HTML(http.StatusOK, tplSettingsApplications)
ctx.JSONError(ctx.Tr("settings.at_least_one_permission"))
return
}
t := &auth_model.AccessToken{
UID: ctx.Doer.ID,
UID: owner.ID,
Name: form.Name,
Scope: scope,
}
@@ -76,8 +101,7 @@ func ApplicationsPost(ctx *context.Context) {
return
}
if exist {
ctx.Flash.Error(ctx.Tr("settings.generate_token_name_duplicate", t.Name))
ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
ctx.JSONErrorWithField(ctx.Tr("settings.generate_token_name_duplicate", t.Name), "name")
return
}
@@ -106,28 +130,41 @@ func ApplicationsPost(ctx *context.Context) {
return
}
audit.Record(ctx, audit_model.UserAccessTokenAdd, ctx.Doer, "token", t.Name, "token_scope", t.Scope)
audit.Record(ctx, audit_model.UserAccessTokenAdd, owner, "token", t.Name, "token_scope", t.Scope)
ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
ctx.Flash.Info(t.Token)
panel := NewAccessTokensPanel(ctx, owner, ctx.Link)
if ctx.Written() {
return
}
panel.NewTokenValue = t.Token
if err := ctx.Render.HTML(ctx.Resp, http.StatusOK, tplAccessTokens, panel, ctx.TemplateContext); err != nil {
ctx.ServerError("Render", err)
}
}
ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
// ApplicationsPost response for add user's access token
func ApplicationsPost(ctx *context.Context) {
CreateAccessToken(ctx, ctx.Doer)
}
// DeleteApplication response for delete user access token
func DeleteApplication(ctx *context.Context) {
t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID)
DeleteAccessToken(ctx, ctx.Doer)
}
func DeleteAccessToken(ctx *context.Context, owner *user_model.User) {
t, err := auth_model.GetAccessTokenByID(ctx, ctx.FormInt64("id"), owner.ID)
if err != nil {
ctx.Flash.Error("GetAccessTokenByID: " + err.Error())
} else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, ctx.Doer.ID); err != nil {
} else if err := auth_model.DeleteAccessTokenByID(ctx, t.ID, owner.ID); err != nil {
ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
} else {
audit.Record(ctx, audit_model.UserAccessTokenRemove, ctx.Doer, "token", t.Name)
audit.Record(ctx, audit_model.UserAccessTokenRemove, owner, "token", t.Name)
ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
}
ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
ctx.JSONRedirect("")
}
// RegenerateAccessToken response for regenerating a user's access token
@@ -143,23 +180,14 @@ func RegenerateAccessToken(ctx *context.Context) {
}
func loadApplicationsData(ctx *context.Context) {
ctx.Data["AccessTokenScopePublicOnly"] = auth_model.AccessTokenScopePublicOnly
tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{UserID: ctx.Doer.ID})
if err != nil {
ctx.ServerError("ListAccessTokens", err)
ctx.Data["AccessTokens"] = NewAccessTokensPanel(ctx, ctx.Doer, ctx.Link)
if ctx.Written() {
return
}
ctx.Data["Tokens"] = tokens
ctx.Data["EnableOAuth2"] = setting.OAuth2.Enabled
// Handle specific ordered token categories for admin or non-admin users
tokenCategoryNames := auth_model.GetAccessTokenCategories()
if !ctx.Doer.IsAdmin {
tokenCategoryNames = util.SliceRemoveAll(tokenCategoryNames, "admin")
}
ctx.Data["TokenCategories"] = tokenCategoryNames
if setting.OAuth2.Enabled {
var err error
ctx.Data["Applications"], err = db.Find[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{
OwnerID: ctx.Doer.ID,
})
+3 -1
View File
@@ -695,7 +695,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// access token applications
m.Combo("").Get(user_setting.Applications).
Post(web.Bind[*forms.NewAccessTokenForm](), user_setting.ApplicationsPost)
Post(user_setting.ApplicationsPost)
m.Post("/delete", user_setting.DeleteApplication)
m.Post("/regenerate", user_setting.RegenerateAccessToken)
})
@@ -824,6 +824,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
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", admin.NewBotTokenPost)
m.Post("/{userid}/access_tokens/delete", admin.DeleteBotToken)
m.Post("/{userid}/orgs/{org_id}/remove", admin.RemoveUserFromOrg)
m.Post("/{userid}/orgs/remove-all", admin.RemoveUserFromAllOrgs)
})