Forbid removing the last admin user (#28337)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
parent
b820019fec
commit
ce0225c1b8
8 changed files with 80 additions and 7 deletions
|
@ -57,6 +57,21 @@ func (err ErrUserOwnPackages) Error() string {
|
||||||
return fmt.Sprintf("user still has ownership of packages [uid: %d]", err.UID)
|
return fmt.Sprintf("user still has ownership of packages [uid: %d]", err.UID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrDeleteLastAdminUser represents a "DeleteLastAdminUser" kind of error.
|
||||||
|
type ErrDeleteLastAdminUser struct {
|
||||||
|
UID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrDeleteLastAdminUser checks if an error is a ErrDeleteLastAdminUser.
|
||||||
|
func IsErrDeleteLastAdminUser(err error) bool {
|
||||||
|
_, ok := err.(ErrDeleteLastAdminUser)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrDeleteLastAdminUser) Error() string {
|
||||||
|
return fmt.Sprintf("can not delete the last admin user [uid: %d]", err.UID)
|
||||||
|
}
|
||||||
|
|
||||||
// ErrNoPendingRepoTransfer is an error type for repositories without a pending
|
// ErrNoPendingRepoTransfer is an error type for repositories without a pending
|
||||||
// transfer request
|
// transfer request
|
||||||
type ErrNoPendingRepoTransfer struct {
|
type ErrNoPendingRepoTransfer struct {
|
||||||
|
|
|
@ -730,9 +730,18 @@ func CreateUser(ctx context.Context, u *User, overwriteDefault ...*CreateUserOve
|
||||||
return committer.Commit()
|
return committer.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsLastAdminUser check whether user is the last admin
|
||||||
|
func IsLastAdminUser(ctx context.Context, user *User) bool {
|
||||||
|
if user.IsAdmin && CountUsers(ctx, &CountUserFilter{IsAdmin: util.OptionalBoolTrue}) <= 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// CountUserFilter represent optional filters for CountUsers
|
// CountUserFilter represent optional filters for CountUsers
|
||||||
type CountUserFilter struct {
|
type CountUserFilter struct {
|
||||||
LastLoginSince *int64
|
LastLoginSince *int64
|
||||||
|
IsAdmin util.OptionalBool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountUsers returns number of users.
|
// CountUsers returns number of users.
|
||||||
|
@ -741,13 +750,25 @@ func CountUsers(ctx context.Context, opts *CountUserFilter) int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func countUsers(ctx context.Context, opts *CountUserFilter) int64 {
|
func countUsers(ctx context.Context, opts *CountUserFilter) int64 {
|
||||||
sess := db.GetEngine(ctx).Where(builder.Eq{"type": "0"})
|
sess := db.GetEngine(ctx)
|
||||||
|
cond := builder.NewCond()
|
||||||
|
cond = cond.And(builder.Eq{"type": UserTypeIndividual})
|
||||||
|
|
||||||
if opts != nil && opts.LastLoginSince != nil {
|
if opts != nil {
|
||||||
sess = sess.Where(builder.Gte{"last_login_unix": *opts.LastLoginSince})
|
if opts.LastLoginSince != nil {
|
||||||
|
cond = cond.And(builder.Gte{"last_login_unix": *opts.LastLoginSince})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.IsAdmin.IsNone() {
|
||||||
|
cond = cond.And(builder.Eq{"is_admin": opts.IsAdmin.IsTrue()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := sess.Where(cond).Count(new(User))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("user.countUsers: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
count, _ := sess.Count(new(User))
|
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -423,6 +423,7 @@ authorization_failed_desc = The authorization failed because we detected an inva
|
||||||
sspi_auth_failed = SSPI authentication failed
|
sspi_auth_failed = SSPI authentication failed
|
||||||
password_pwned = The password you chose is on a <a target="_blank" rel="noopener noreferrer" href="https://haveibeenpwned.com/Passwords">list of stolen passwords</a> previously exposed in public data breaches. Please try again with a different password and consider changing this password elsewhere too.
|
password_pwned = The password you chose is on a <a target="_blank" rel="noopener noreferrer" href="https://haveibeenpwned.com/Passwords">list of stolen passwords</a> previously exposed in public data breaches. Please try again with a different password and consider changing this password elsewhere too.
|
||||||
password_pwned_err = Could not complete request to HaveIBeenPwned
|
password_pwned_err = Could not complete request to HaveIBeenPwned
|
||||||
|
last_admin = You cannot remove the last admin. There must be at least one admin.
|
||||||
|
|
||||||
[mail]
|
[mail]
|
||||||
view_it_on = View it on %s
|
view_it_on = View it on %s
|
||||||
|
@ -588,6 +589,8 @@ org_still_own_packages = "This organization still owns one or more packages, del
|
||||||
|
|
||||||
target_branch_not_exist = Target branch does not exist.
|
target_branch_not_exist = Target branch does not exist.
|
||||||
|
|
||||||
|
admin_cannot_delete_self = You cannot delete yourself when you are an admin. Please remove your admin privileges first.
|
||||||
|
|
||||||
[user]
|
[user]
|
||||||
change_avatar = Change your avatar…
|
change_avatar = Change your avatar…
|
||||||
joined_on = Joined on %s
|
joined_on = Joined on %s
|
||||||
|
|
|
@ -183,6 +183,8 @@ func EditUser(ctx *context.APIContext) {
|
||||||
// responses:
|
// responses:
|
||||||
// "200":
|
// "200":
|
||||||
// "$ref": "#/responses/User"
|
// "$ref": "#/responses/User"
|
||||||
|
// "400":
|
||||||
|
// "$ref": "#/responses/error"
|
||||||
// "403":
|
// "403":
|
||||||
// "$ref": "#/responses/forbidden"
|
// "$ref": "#/responses/forbidden"
|
||||||
// "422":
|
// "422":
|
||||||
|
@ -264,6 +266,10 @@ func EditUser(ctx *context.APIContext) {
|
||||||
ctx.ContextUser.Visibility = api.VisibilityModes[form.Visibility]
|
ctx.ContextUser.Visibility = api.VisibilityModes[form.Visibility]
|
||||||
}
|
}
|
||||||
if form.Admin != nil {
|
if form.Admin != nil {
|
||||||
|
if !*form.Admin && user_model.IsLastAdminUser(ctx, ctx.ContextUser) {
|
||||||
|
ctx.Error(http.StatusBadRequest, "LastAdmin", ctx.Tr("auth.last_admin"))
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx.ContextUser.IsAdmin = *form.Admin
|
ctx.ContextUser.IsAdmin = *form.Admin
|
||||||
}
|
}
|
||||||
if form.AllowGitHook != nil {
|
if form.AllowGitHook != nil {
|
||||||
|
@ -341,7 +347,8 @@ func DeleteUser(ctx *context.APIContext) {
|
||||||
if err := user_service.DeleteUser(ctx, ctx.ContextUser, ctx.FormBool("purge")); err != nil {
|
if err := user_service.DeleteUser(ctx, ctx.ContextUser, ctx.FormBool("purge")); err != nil {
|
||||||
if models.IsErrUserOwnRepos(err) ||
|
if models.IsErrUserOwnRepos(err) ||
|
||||||
models.IsErrUserHasOrgs(err) ||
|
models.IsErrUserHasOrgs(err) ||
|
||||||
models.IsErrUserOwnPackages(err) {
|
models.IsErrUserOwnPackages(err) ||
|
||||||
|
models.IsErrDeleteLastAdminUser(err) {
|
||||||
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
||||||
} else {
|
} else {
|
||||||
ctx.Error(http.StatusInternalServerError, "DeleteUser", err)
|
ctx.Error(http.StatusInternalServerError, "DeleteUser", err)
|
||||||
|
|
|
@ -436,6 +436,12 @@ func EditUserPost(ctx *context.Context) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check whether user is the last admin
|
||||||
|
if !form.Admin && user_model.IsLastAdminUser(ctx, u) {
|
||||||
|
ctx.RenderWithErr(ctx.Tr("auth.last_admin"), tplUserEdit, &form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
u.LoginName = form.LoginName
|
u.LoginName = form.LoginName
|
||||||
u.FullName = form.FullName
|
u.FullName = form.FullName
|
||||||
emailChanged := !strings.EqualFold(u.Email, form.Email)
|
emailChanged := !strings.EqualFold(u.Email, form.Email)
|
||||||
|
@ -503,7 +509,10 @@ func DeleteUser(ctx *context.Context) {
|
||||||
ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid")))
|
ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid")))
|
||||||
case models.IsErrUserOwnPackages(err):
|
case models.IsErrUserOwnPackages(err):
|
||||||
ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages"))
|
ctx.Flash.Error(ctx.Tr("admin.users.still_own_packages"))
|
||||||
ctx.Redirect(setting.AppSubURL + "/admin/users/" + ctx.Params(":userid"))
|
ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid")))
|
||||||
|
case models.IsErrDeleteLastAdminUser(err):
|
||||||
|
ctx.Flash.Error(ctx.Tr("auth.last_admin"))
|
||||||
|
ctx.Redirect(setting.AppSubURL + "/admin/users/" + url.PathEscape(ctx.Params(":userid")))
|
||||||
default:
|
default:
|
||||||
ctx.ServerError("DeleteUser", err)
|
ctx.ServerError("DeleteUser", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -244,6 +244,13 @@ func DeleteAccount(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// admin should not delete themself
|
||||||
|
if ctx.Doer.IsAdmin {
|
||||||
|
ctx.Flash.Error(ctx.Tr("form.admin_cannot_delete_self"))
|
||||||
|
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil {
|
if err := user.DeleteUser(ctx, ctx.Doer, false); err != nil {
|
||||||
switch {
|
switch {
|
||||||
case models.IsErrUserOwnRepos(err):
|
case models.IsErrUserOwnRepos(err):
|
||||||
|
@ -255,6 +262,9 @@ func DeleteAccount(ctx *context.Context) {
|
||||||
case models.IsErrUserOwnPackages(err):
|
case models.IsErrUserOwnPackages(err):
|
||||||
ctx.Flash.Error(ctx.Tr("form.still_own_packages"))
|
ctx.Flash.Error(ctx.Tr("form.still_own_packages"))
|
||||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||||
|
case models.IsErrDeleteLastAdminUser(err):
|
||||||
|
ctx.Flash.Error(ctx.Tr("auth.last_admin"))
|
||||||
|
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
|
||||||
default:
|
default:
|
||||||
ctx.ServerError("DeleteUser", err)
|
ctx.ServerError("DeleteUser", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,6 +129,10 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error {
|
||||||
return fmt.Errorf("%s is an organization not a user", u.Name)
|
return fmt.Errorf("%s is an organization not a user", u.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user_model.IsLastAdminUser(ctx, u) {
|
||||||
|
return models.ErrDeleteLastAdminUser{UID: u.ID}
|
||||||
|
}
|
||||||
|
|
||||||
if purge {
|
if purge {
|
||||||
// Disable the user first
|
// Disable the user first
|
||||||
// NOTE: This is deliberately not within a transaction as it must disable the user immediately to prevent any further action by the user to be purged.
|
// NOTE: This is deliberately not within a transaction as it must disable the user immediately to prevent any further action by the user to be purged.
|
||||||
|
@ -295,7 +299,8 @@ func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) error {
|
||||||
}
|
}
|
||||||
if err := DeleteUser(ctx, u, false); err != nil {
|
if err := DeleteUser(ctx, u, false); err != nil {
|
||||||
// Ignore users that were set inactive by admin.
|
// Ignore users that were set inactive by admin.
|
||||||
if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) || models.IsErrUserOwnPackages(err) {
|
if models.IsErrUserOwnRepos(err) || models.IsErrUserHasOrgs(err) ||
|
||||||
|
models.IsErrUserOwnPackages(err) || models.IsErrDeleteLastAdminUser(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|
3
templates/swagger/v1_json.tmpl
generated
3
templates/swagger/v1_json.tmpl
generated
|
@ -677,6 +677,9 @@
|
||||||
"200": {
|
"200": {
|
||||||
"$ref": "#/responses/User"
|
"$ref": "#/responses/User"
|
||||||
},
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
"403": {
|
"403": {
|
||||||
"$ref": "#/responses/forbidden"
|
"$ref": "#/responses/forbidden"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue