Add a option "--user-type bot" to admin user create, improve role display (#27885)
Some checks failed
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-docker-rootful (push) Has been cancelled
release-nightly / nightly-docker-rootless (push) Has been cancelled

Partially solve #13044

Fix #33295

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
mscherer 2025-02-07 09:41:55 +01:00 committed by GitHub
parent 1ec8d80fa3
commit 063c23e1bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 118 additions and 76 deletions

View File

@ -31,6 +31,11 @@ var microcmdUserCreate = &cli.Command{
Name: "username", Name: "username",
Usage: "Username", Usage: "Username",
}, },
&cli.StringFlag{
Name: "user-type",
Usage: "Set user's type: individual or bot",
Value: "individual",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "password", Name: "password",
Usage: "User password", Usage: "User password",
@ -77,6 +82,22 @@ func runCreateUser(c *cli.Context) error {
return err return err
} }
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"))
}
if userType != user_model.UserTypeIndividual {
// Some other commands like "change-password" also only support individual users.
// 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")
}
}
if c.IsSet("name") && c.IsSet("username") { if c.IsSet("name") && c.IsSet("username") {
return errors.New("cannot set both --name and --username flags") return errors.New("cannot set both --name and --username flags")
} }
@ -118,16 +139,19 @@ func runCreateUser(c *cli.Context) error {
return err return err
} }
fmt.Printf("generated random password is '%s'\n", password) fmt.Printf("generated random password is '%s'\n", password)
} else { } else if userType == user_model.UserTypeIndividual {
return errors.New("must set either password or random-password flag") return errors.New("must set either password or random-password flag")
} }
isAdmin := c.Bool("admin") isAdmin := c.Bool("admin")
mustChangePassword := true // always default to true mustChangePassword := true // always default to true
if c.IsSet("must-change-password") { 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")
}
// if the flag is set, use the value provided by the user // if the flag is set, use the value provided by the user
mustChangePassword = c.Bool("must-change-password") mustChangePassword = c.Bool("must-change-password")
} else { } else if userType == user_model.UserTypeIndividual {
// check whether there are users in the database // check whether there are users in the database
hasUserRecord, err := db.IsTableNotEmpty(&user_model.User{}) hasUserRecord, err := db.IsTableNotEmpty(&user_model.User{})
if err != nil { if err != nil {
@ -151,8 +175,9 @@ func runCreateUser(c *cli.Context) error {
u := &user_model.User{ u := &user_model.User{
Name: username, Name: username,
Email: c.String("email"), Email: c.String("email"),
Passwd: password,
IsAdmin: isAdmin, IsAdmin: isAdmin,
Type: userType,
Passwd: password,
MustChangePassword: mustChangePassword, MustChangePassword: mustChangePassword,
Visibility: visibility, Visibility: visibility,
} }

View File

@ -13,32 +13,54 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestAdminUserCreate(t *testing.T) { func TestAdminUserCreate(t *testing.T) {
app := NewMainApp(AppVersion{}) app := NewMainApp(AppVersion{})
reset := func() { reset := func() {
assert.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.User{})) require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.User{}))
assert.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.EmailAddress{})) require.NoError(t, db.TruncateBeans(db.DefaultContext, &user_model.EmailAddress{}))
} }
type createCheck struct{ IsAdmin, MustChangePassword bool } t.Run("MustChangePassword", func(t *testing.T) {
createUser := func(name, args string) createCheck { type check struct {
assert.NoError(t, app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s --password foobar", name, name, args)))) IsAdmin bool
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: name}) MustChangePassword bool
return createCheck{u.IsAdmin, u.MustChangePassword} }
} createCheck := func(name, args string) check {
reset() require.NoError(t, app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s --password foobar", name, name, args))))
assert.Equal(t, createCheck{IsAdmin: false, MustChangePassword: false}, createUser("u", ""), "first non-admin user doesn't need to change password") u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: name})
return check{IsAdmin: u.IsAdmin, MustChangePassword: u.MustChangePassword}
}
reset()
assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u", ""), "first non-admin user doesn't need to change password")
reset() reset()
assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: false}, createUser("u", "--admin"), "first admin user doesn't need to change password") assert.Equal(t, check{IsAdmin: true, MustChangePassword: false}, createCheck("u", "--admin"), "first admin user doesn't need to change password")
reset() reset()
assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: true}, createUser("u", "--admin --must-change-password")) assert.Equal(t, check{IsAdmin: true, MustChangePassword: true}, createCheck("u", "--admin --must-change-password"))
assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: true}, createUser("u2", "--admin")) assert.Equal(t, check{IsAdmin: true, MustChangePassword: true}, createCheck("u2", "--admin"))
assert.Equal(t, createCheck{IsAdmin: true, MustChangePassword: false}, createUser("u3", "--admin --must-change-password=false")) assert.Equal(t, check{IsAdmin: true, MustChangePassword: false}, createCheck("u3", "--admin --must-change-password=false"))
assert.Equal(t, createCheck{IsAdmin: false, MustChangePassword: true}, createUser("u4", "")) assert.Equal(t, check{IsAdmin: false, MustChangePassword: true}, createCheck("u4", ""))
assert.Equal(t, createCheck{IsAdmin: false, MustChangePassword: false}, createUser("u5", "--must-change-password=false")) assert.Equal(t, check{IsAdmin: false, MustChangePassword: false}, createCheck("u5", "--must-change-password=false"))
})
t.Run("UserType", func(t *testing.T) {
createUser := func(name, args string) error {
return app.Run(strings.Fields(fmt.Sprintf("./gitea admin user create --username %s --email %s@gitea.local %s", name, name, args)))
}
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.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.Equal(t, "", u.Passwd)
})
} }

View File

@ -385,11 +385,12 @@ func (u *User) ValidatePassword(passwd string) bool {
} }
// IsPasswordSet checks if the password is set or left empty // IsPasswordSet checks if the password is set or left empty
// TODO: It's better to clarify the "password" behavior for different types (individual, bot)
func (u *User) IsPasswordSet() bool { func (u *User) IsPasswordSet() bool {
return len(u.Passwd) != 0 return u.Passwd != ""
} }
// IsOrganization returns true if user is actually a organization. // IsOrganization returns true if user is actually an organization.
func (u *User) IsOrganization() bool { func (u *User) IsOrganization() bool {
return u.Type == UserTypeOrganization return u.Type == UserTypeOrganization
} }
@ -399,13 +400,14 @@ func (u *User) IsIndividual() bool {
return u.Type == UserTypeIndividual return u.Type == UserTypeIndividual
} }
func (u *User) IsUser() bool { // IsTypeBot returns whether the user is of type bot
return u.Type == UserTypeIndividual || u.Type == UserTypeBot func (u *User) IsTypeBot() bool {
return u.Type == UserTypeBot
} }
// IsBot returns whether or not the user is of type bot // IsTokenAccessAllowed returns whether the user is an individual or a bot (which allows for token access)
func (u *User) IsBot() bool { func (u *User) IsTokenAccessAllowed() bool {
return u.Type == UserTypeBot return u.Type == UserTypeIndividual || u.Type == UserTypeBot
} }
// DisplayName returns full name if it's not empty, // DisplayName returns full name if it's not empty,

View File

@ -56,7 +56,7 @@ func NewActionsUser() *User {
Email: ActionsUserEmail, Email: ActionsUserEmail,
KeepEmailPrivate: true, KeepEmailPrivate: true,
LoginName: ActionsUserName, LoginName: ActionsUserName,
Type: UserTypeIndividual, Type: UserTypeBot,
AllowCreateOrganization: true, AllowCreateOrganization: true,
Visibility: structs.VisibleTypePublic, Visibility: structs.VisibleTypePublic,
} }

View File

@ -268,12 +268,12 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) {
return return
} }
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser): case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):
if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic { if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users") ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users")
return return
} }
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub): case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):
if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic { if ctx.ContextUser != nil && ctx.ContextUser.IsTokenAccessAllowed() && ctx.ContextUser.Visibility != api.VisibleTypePublic {
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub") ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub")
return return
} }

View File

@ -4,7 +4,6 @@
package repo package repo
import ( import (
stdCtx "context"
"fmt" "fmt"
"math/big" "math/big"
"net/http" "net/http"
@ -40,86 +39,80 @@ import (
) )
// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue // roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue
func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) { func roleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, permsCache map[int64]access_model.Permission, issue *issues_model.Issue, hasOriginalAuthor bool) (roleDesc issues_model.RoleDescriptor, err error) {
roleDescriptor := issues_model.RoleDescriptor{}
if hasOriginalAuthor { if hasOriginalAuthor {
return roleDescriptor, nil // the poster is a migrated user, so no need to detect the role
return roleDesc, nil
} }
var perm access_model.Permission if poster.IsGhost() || !poster.IsIndividual() {
var err error return roleDesc, nil
if permsCache != nil { }
var ok bool
perm, ok = permsCache[poster.ID] roleDesc.IsPoster = issue.IsPoster(poster.ID) // check whether the comment's poster is the issue's poster
if !ok {
perm, err = access_model.GetUserRepoPermission(ctx, repo, poster) // Guess the role of the poster in the repo by permission
if err != nil { perm, hasPermCache := permsCache[poster.ID]
return roleDescriptor, err if !hasPermCache {
}
}
permsCache[poster.ID] = perm
} else {
perm, err = access_model.GetUserRepoPermission(ctx, repo, poster) perm, err = access_model.GetUserRepoPermission(ctx, repo, poster)
if err != nil { if err != nil {
return roleDescriptor, err return roleDesc, err
} }
} }
if permsCache != nil {
// If the poster is the actual poster of the issue, enable Poster role. permsCache[poster.ID] = perm
roleDescriptor.IsPoster = issue.IsPoster(poster.ID) }
// Check if the poster is owner of the repo. // Check if the poster is owner of the repo.
if perm.IsOwner() { if perm.IsOwner() {
// If the poster isn't an admin, enable the owner role. // If the poster isn't a site admin, then is must be the repo's owner
if !poster.IsAdmin { if !poster.IsAdmin {
roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner roleDesc.RoleInRepo = issues_model.RoleRepoOwner
return roleDescriptor, nil return roleDesc, nil
} }
// Otherwise (poster is site admin), check if poster is the real repo admin.
// Otherwise check if poster is the real repo admin. isRealRepoAdmin, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
if err != nil { if err != nil {
return roleDescriptor, err return roleDesc, err
} }
if ok { if isRealRepoAdmin {
roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner roleDesc.RoleInRepo = issues_model.RoleRepoOwner
return roleDescriptor, nil return roleDesc, nil
} }
} }
// If repo is organization, check Member role // If repo is organization, check Member role
if err := repo.LoadOwner(ctx); err != nil { if err = repo.LoadOwner(ctx); err != nil {
return roleDescriptor, err return roleDesc, err
} }
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil { if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil {
return roleDescriptor, err return roleDesc, err
} else if isMember { } else if isMember {
roleDescriptor.RoleInRepo = issues_model.RoleRepoMember roleDesc.RoleInRepo = issues_model.RoleRepoMember
return roleDescriptor, nil return roleDesc, nil
} }
} }
// If the poster is the collaborator of the repo // If the poster is the collaborator of the repo
if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil { if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil {
return roleDescriptor, err return roleDesc, err
} else if isCollaborator { } else if isCollaborator {
roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator roleDesc.RoleInRepo = issues_model.RoleRepoCollaborator
return roleDescriptor, nil return roleDesc, nil
} }
hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID) hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID)
if err != nil { if err != nil {
return roleDescriptor, err return roleDesc, err
} else if hasMergedPR { } else if hasMergedPR {
roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor roleDesc.RoleInRepo = issues_model.RoleRepoContributor
} else if issue.IsPull { } else if issue.IsPull {
// only display first time contributor in the first opening pull request // only display first time contributor in the first opening pull request
roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor roleDesc.RoleInRepo = issues_model.RoleRepoFirstTimeContributor
} }
return roleDescriptor, nil return roleDesc, nil
} }
func getBranchData(ctx *context.Context, issue *issues_model.Issue) { func getBranchData(ctx *context.Context, issue *issues_model.Issue) {

View File

@ -1 +1 @@
<a class="author text black tw-font-semibold muted"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsBot}}<span class="ui basic label tw-p-1 tw-align-baseline">bot</span>{{end}} <a class="author text black tw-font-semibold muted"{{if gt .ID 0}} href="{{.HomeLink}}"{{end}}>{{.GetDisplayName}}</a>{{if .IsTypeBot}}<span class="ui basic label tw-p-1 tw-align-baseline">bot</span>{{end}}