Template
1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo synced 2024-11-29 05:06:11 +01:00

Forbid removing the last admin user (#28337) (#28793)

Backport #28337 by @yp05327

Co-authored-by: yp05327 <576951401@qq.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
(cherry picked from commit 376fa0d8c4)
This commit is contained in:
Giteabot 2024-01-16 09:51:46 +08:00 committed by Earl Warren
parent c287b1383f
commit 5124be72aa
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
8 changed files with 80 additions and 7 deletions

View file

@ -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 {

View file

@ -717,9 +717,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.
@ -728,13 +737,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
} }

View file

@ -424,6 +424,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
@ -594,6 +595,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

View file

@ -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)

View file

@ -429,6 +429,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)
@ -496,7 +502,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)
} }

View file

@ -255,6 +255,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):
@ -266,6 +273,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)
} }

View file

@ -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

View file

@ -660,6 +660,9 @@
"200": { "200": {
"$ref": "#/responses/User" "$ref": "#/responses/User"
}, },
"400": {
"$ref": "#/responses/error"
},
"403": { "403": {
"$ref": "#/responses/forbidden" "$ref": "#/responses/forbidden"
}, },