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
+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,
})