mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 19:06:18 +01:00 
			
		
		
		
	Add a option "--user-type bot" to admin user create, improve role display (#27885)
Partially solve #13044 Fix #33295 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -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, | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -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) | ||||||
|  | 	}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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, | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -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 | ||||||
| 			} | 			} | ||||||
|   | |||||||
| @@ -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) { | ||||||
|   | |||||||
| @@ -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}} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user