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
+3
View File
@@ -82,6 +82,9 @@ func (b *Basic) VerifyAuthToken(req *http.Request, w http.ResponseWriter, store
log.Error("GetUserByID: %v", err)
return nil, err
}
if !u.IsIndividual() {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
store.GetData()["LoginMethod"] = OAuth2TokenMethodName
store.GetData()["ApiTokenScope"] = accessTokenScope
+6 -4
View File
@@ -120,11 +120,13 @@ func (o *OAuth2) userFromToken(ctx context.Context, tokenSHA string, store DataS
// Otherwise, check if this is an OAuth access token
accessTokenScope, uid, grantID := GetOAuthAccessTokenScopeAndUserID(ctx, tokenSHA)
if uid != 0 {
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
user, err := user_model.GetUserByID(ctx, uid)
if err != nil || !user.IsIndividual() {
return nil, err
}
return user_model.GetUserByID(ctx, uid)
store.GetData()["ApiTokenScope"] = accessTokenScope
setAuthCredential(store, credentialOAuth2Grant, grantID)
return user, nil
}
t, err := auth_model.GetAccessTokenBySHA(ctx, tokenSHA)
if err != nil {
+3 -3
View File
@@ -114,9 +114,9 @@ func (r *ReverseProxy) Verify(req *http.Request, w http.ResponseWriter, store Da
}
if user == nil {
user = r.getUserFromAuthEmail(req)
if user == nil {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
}
if user == nil || !user.IsIndividual() {
return nil, nil //nolint:nilnil // the auth method is not applicable
}
if r.CreateSession && sess != nil {
+17
View File
@@ -4,6 +4,7 @@
package auth
import (
"net/http"
"testing"
"gitea.dev/models/unittest"
@@ -17,6 +18,22 @@ import (
"github.com/stretchr/testify/require"
)
func TestReverseProxyIgnoresBot(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")()
defer test.MockVariableValue(&setting.ReverseProxyAuthEmail, "X-WEBAUTH-EMAIL")()
defer test.MockVariableValue(&setting.Service.EnableReverseProxyEmail, true)()
require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 2, Type: user_model.UserTypeBot}, "type"))
req, err := http.NewRequest(http.MethodGet, "/", nil)
require.NoError(t, err)
req.Header.Set(setting.ReverseProxyAuthUser, "user2")
req.Header.Set(setting.ReverseProxyAuthEmail, "user2@example.com")
user, err := (&ReverseProxy{}).Verify(req, nil, nil, nil)
require.NoError(t, err)
assert.Nil(t, user)
}
func TestReverseProxyLastLogin(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.ReverseProxyAuthUser, "X-WEBAUTH-USER")()
+6
View File
@@ -50,6 +50,12 @@ func (s *Session) Verify(req *http.Request, w http.ResponseWriter, store DataSto
return nil, nil //nolint:nilnil // the auth method is not applicable
}
// sessions can't be enumerated per user, so one opened before a conversion to bot is rejected here
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
}
+38
View File
@@ -0,0 +1,38 @@
// 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{}
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)
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)
}
+6 -2
View File
@@ -122,10 +122,14 @@ 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():
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) {
+47
View File
@@ -0,0 +1,47 @@
// 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"
)
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)
}
const mockBotSourceType auth_model.Type = 100
func TestUserSignIn_BotCannotSignIn(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
auth_model.RegisterTypeConfig(mockBotSourceType, &mockBotSource{})
bot := &user_model.User{Name: "test-bot", Email: "test-bot@example.com", Type: user_model.UserTypeBot, IsActive: true}
require.NoError(t, user_model.AdminCreateUser(t.Context(), bot, &user_model.Meta{}))
require.NoError(t, db.Insert(t.Context(), &auth_model.Source{
Type: mockBotSourceType,
Name: "mock-bot-source",
IsActive: true,
Cfg: &mockBotSource{},
}))
_, _, err := UserSignIn(t.Context(), "test-bot", "")
assert.ErrorAs(t, err, &user_model.ErrUserNotExist{})
}
+3
View File
@@ -119,6 +119,9 @@ func (s *SSPI) Verify(req *http.Request, w http.ResponseWriter, store DataStore,
log.Error("CreateUser: %v", err)
return nil, err
}
} else if !user.IsIndividual() {
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 {
+25
View File
@@ -9,8 +9,32 @@ import (
"gitea.dev/models/perm"
user_model "gitea.dev/models/user"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
)
func userTypeToString(t user_model.UserType) api.UserTypeString {
switch t {
case user_model.UserTypeOrganization, user_model.UserTypeOrganizationReserved:
return api.UserTypeStringOrganization
case user_model.UserTypeBot:
return api.UserTypeStringBot
default:
return api.UserTypeStringUser
}
}
// UserTypeFromString parses a user type an admin may create or convert to
func UserTypeFromString(s api.UserTypeString) (user_model.UserType, error) {
switch s {
case api.UserTypeStringUser:
return user_model.UserTypeIndividual, nil
case api.UserTypeStringBot:
return user_model.UserTypeBot, nil
default:
return 0, util.NewInvalidArgumentErrorf("invalid user type %q (expected %s, %s)", s, api.UserTypeStringUser, api.UserTypeStringBot)
}
}
// ToUser convert user_model.User to api.User
// if doer is set, private information is added if the doer has the permission to see it
func ToUser(ctx context.Context, user, doer *user_model.User) *api.User {
@@ -50,6 +74,7 @@ func toUser(ctx context.Context, user *user_model.User, signed, authed bool) *ap
result := &api.User{
ID: user.ID,
UserName: user.Name,
Type: userTypeToString(user.Type),
FullName: user.FullName,
Email: user.GetPlaceholderEmail(),
AvatarURL: user.AvatarLink(ctx),
+7 -5
View File
@@ -13,9 +13,10 @@ type AdminCreateUserForm struct {
middleware.FormDefaultValidator
LoginType string `binding:"Required"`
LoginName string
UserName string `binding:"Required;Username;MaxSize(40)"`
Email string `binding:"Required;Email;MaxSize(254)"`
Password string `binding:"MaxSize(255)"`
UserName string `binding:"Required;Username;MaxSize(40)"`
UserType structs.UserTypeString `binding:"In(,User,Bot)"`
Email string `binding:"Required;Email;MaxSize(254)"`
Password string `binding:"MaxSize(255)"`
SendNotify bool
MustChangePassword bool
Visibility structs.VisibleType
@@ -39,8 +40,9 @@ type AdminEditBadgeForm struct {
// AdminEditUserForm form for admin to create user
type AdminEditUserForm struct {
middleware.FormDefaultValidator
LoginType string `binding:"Required"`
UserName string `binding:"Username;MaxSize(40)"`
LoginType string `binding:"Required"`
UserType structs.UserTypeString `binding:"In(,User,Bot)"`
UserName string `binding:"Username;MaxSize(40)"`
LoginName string
FullName string `binding:"MaxSize(100)"`
Email string `binding:"Required;Email;MaxSize(254)"`
+2 -4
View File
@@ -120,8 +120,7 @@ func mailIssueCommentBatch(ctx context.Context, comment *mailComment, users []*u
langMap := make(map[string][]*user_model.User)
for _, user := range users {
if !user.IsActive {
// Exclude deactivated users
if !user.IsMailable() {
continue
}
// At this point we exclude:
@@ -205,8 +204,7 @@ func SendIssueAssignedMail(ctx context.Context, issue *issues_model.Issue, doer
langMap := make(map[string][]*user_model.User)
for _, user := range recipients {
if !user.IsActive {
// don't send emails to inactive users
if !user.IsMailable() {
continue
}
langMap[user.Language] = append(langMap[user.Language], user)
+5 -3
View File
@@ -38,8 +38,7 @@ func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.
langMap := make(map[string][]*user_model.User)
for _, user := range users {
if !user.IsActive {
// don't send emails to inactive users
if !user.IsMailable() {
continue
}
langMap[user.Language] = append(langMap[user.Language], user)
@@ -54,6 +53,9 @@ func SendRepoTransferNotifyMail(ctx context.Context, doer, newOwner *user_model.
return nil
}
if newOwner.IsTypeBot() {
return nil
}
return sendRepoTransferNotifyMailPerLang(newOwner.Language, newOwner, doer, []*user_model.User{newOwner}, repo)
}
@@ -98,7 +100,7 @@ func sendRepoTransferNotifyMailPerLang(lang string, newOwner, doer *user_model.U
// SendCollaboratorMail sends mail notification to new collaborator.
func SendCollaboratorMail(u, doer *user_model.User, repo *repo_model.Repository) {
if setting.MailService == nil || !u.IsActive {
if setting.MailService == nil || !u.IsMailable() {
return
}
locale := translation.NewLocale(u.Language)
+22
View File
@@ -168,6 +168,28 @@ func TestMailMentionsComment(t *testing.T) {
assert.Equal(t, 3, mails)
}
func TestMailsSkipBots(t *testing.T) {
doer, repo, issue, comment := prepareMailerTest(t)
comment.Poster = doer
var recipients []string
defer test.MockVariableValue(&SendAsync, func(msgs ...*sender_service.Message) {
for _, msg := range msgs {
recipients = append(recipients, msg.To)
}
})()
require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 5, Type: user_model.UserTypeBot}, "type"))
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
bot := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
require.NoError(t, SendIssueAssignedMail(t.Context(), issue, doer, "", comment, []*user_model.User{user, bot}))
require.NoError(t, MailParticipantsComment(t.Context(), comment, activities_model.ActionCommentIssue, issue, []*user_model.User{bot}))
require.NoError(t, SendRepoTransferNotifyMail(t.Context(), doer, bot, repo))
SendCollaboratorMail(bot, doer, repo)
SendRegisterNotifyMail(bot)
assert.Contains(t, recipients, user.Email)
assert.NotContains(t, strings.Join(recipients, " "), bot.Email)
}
func TestComposeIssueMessage(t *testing.T) {
doer, _, issue, _ := prepareMailerTest(t)
+1 -2
View File
@@ -101,8 +101,7 @@ func SendActivateEmailMail(u *user_model.User, email string) {
// SendRegisterNotifyMail triggers a notify e-mail by admin created a account.
func SendRegisterNotifyMail(u *user_model.User) {
if setting.MailService == nil || !u.IsActive {
// No mail service configured OR user is inactive
if setting.MailService == nil || !u.IsMailable() {
return
}
locale := translation.NewLocale(u.Language)
+29 -1
View File
@@ -14,6 +14,7 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
"gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/services/audit"
)
@@ -55,6 +56,7 @@ type UpdateOptions struct {
AllowCreateOrganization optional.Option[bool]
IsActive optional.Option[bool]
IsAdmin optional.Option[UpdateOptionField[bool]]
UserType optional.Option[user_model.UserType]
EmailNotificationsPreference optional.Option[string]
SetLastLogin bool
RepoAdminChangeTeamAccess optional.Option[bool]
@@ -63,7 +65,7 @@ type UpdateOptions struct {
func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error {
cols := make([]string, 0, 20)
oldIsActive, oldIsRestricted, oldIsAdmin, oldVisibility := u.IsActive, u.IsRestricted, u.IsAdmin, u.Visibility
oldIsActive, oldIsRestricted, oldIsAdmin, oldVisibility, oldType := u.IsActive, u.IsRestricted, u.IsAdmin, u.Visibility, u.Type
if opts.KeepEmailPrivate.Has() {
u.KeepEmailPrivate = opts.KeepEmailPrivate.Value()
@@ -175,6 +177,14 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
cols = append(cols, "repo_admin_change_team_access")
}
if opts.UserType.Has() && opts.UserType.Value() != u.Type {
if err := CheckConvertUserType(u); err != nil {
return err
}
u.Type = opts.UserType.Value()
cols = append(cols, "type")
}
if opts.EmailNotificationsPreference.Has() {
u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value()
@@ -203,6 +213,9 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er
if u.Visibility != oldVisibility {
audit.Record(ctx, audit_model.UserVisibility, u, "old_visibility", oldVisibility.String(), "new_visibility", u.Visibility.String())
}
if u.Type != oldType {
audit.Record(ctx, audit_model.UserType, u, "user_type", u.Type.DisplayName())
}
return nil
}
@@ -216,6 +229,9 @@ type UpdateAuthOptions struct {
}
func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions) error {
if u.IsTypeBot() && (opts.Password.Has() || opts.LoginSource.Value() != 0 || opts.LoginName.Value() != "") {
return util.NewInvalidArgumentErrorf("a bot account cannot have a password or authentication source")
}
loginSourceChanged := false
authSourceName := ""
if opts.LoginSource.Has() {
@@ -279,3 +295,15 @@ func UpdateAuth(ctx context.Context, u *user_model.User, opts *UpdateAuthOptions
return nil
}
func CheckConvertUserType(u *user_model.User) error {
switch {
case u.IsAdmin:
return user_model.ErrBotCanNotBeAdmin
case !u.IsIndividual() && !u.IsTypeBot():
return user_model.ErrUserTypeCanNotConvert
case !u.IsLocal():
return user_model.ErrBotMustBeLocal
}
return nil
}
+40
View File
@@ -6,6 +6,8 @@ package user
import (
"testing"
audit_model "gitea.dev/models/audit"
auth_model "gitea.dev/models/auth"
"gitea.dev/models/unittest"
user_model "gitea.dev/models/user"
password_module "gitea.dev/modules/auth/password"
@@ -13,6 +15,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 +156,40 @@ func TestUpdateUserVisibility(t *testing.T) {
Visibility: optional.Some(structs.VisibleTypePublic),
}))
}
func TestConvertUserType(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
before := *user
tokensBefore := unittest.GetCount(t, &auth_model.AccessToken{UID: user.ID})
assert.NotEmpty(t, before.Passwd)
assert.Positive(t, tokensBefore)
defer test.MockVariableValue(&setting.Audit.RecordOutput, setting.AuditRecordOutputDatabase)()
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{UserType: optional.Some(user_model.UserTypeBot)}))
assert.True(t, user.IsTypeBot())
unittest.AssertExistsAndLoadBean(t, &audit_model.Event{Action: audit_model.UserType, ScopeType: audit_model.ScopeUser, ScopeID: user.ID})
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
before.Type, before.UpdatedUnix = user_model.UserTypeBot, user.UpdatedUnix
assert.Equal(t, before, *user)
assert.Equal(t, tokensBefore, unittest.GetCount(t, &auth_model.AccessToken{UID: user.ID}))
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.ErrorIs(t, UpdateUser(t.Context(), user, &UpdateOptions{IsAdmin: UpdateOptionFieldFromValue(true)}), user_model.ErrBotCanNotBeAdmin)
assert.False(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).IsAdmin)
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, UpdateUser(t.Context(), user, &UpdateOptions{UserType: optional.Some(user_model.UserTypeIndividual)}))
assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).IsIndividual())
toBot := &UpdateOptions{UserType: optional.Some(user_model.UserTypeBot)}
assert.ErrorIs(t, UpdateUser(t.Context(), unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}), toBot), user_model.ErrUserTypeCanNotConvert)
assert.ErrorIs(t, UpdateUser(t.Context(), unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), toBot), user_model.ErrBotCanNotBeAdmin)
assert.True(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).IsIndividual())
assert.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{ID: 4, LoginType: auth_model.LDAP}, "login_type"))
assert.ErrorIs(t, UpdateUser(t.Context(), unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}), toBot), user_model.ErrBotMustBeLocal)
}