0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-03 19:03:05 +02:00

Restrict organization danger zone actions to site admins via admin config

This commit is contained in:
Lunny Xiao 2026-04-02 21:59:52 -07:00
parent 6eed75af24
commit 76bb60215b
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
6 changed files with 94 additions and 0 deletions

View File

@ -1623,6 +1623,9 @@ LEVEL = Info
;; - change_username: a user cannot change their username
;; - change_full_name: a user cannot change their full name
;;EXTERNAL_USER_DISABLE_FEATURES =
;; Disabled features for organizations, currently supported: danger_zone
;; - danger_zone: only site administrators can delete, rename, or change organization visibility
;ORG_DISABLED_FEATURES =
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -14,6 +14,7 @@ var Admin struct {
DefaultEmailNotification string
UserDisabledFeatures container.Set[string]
ExternalUserDisableFeatures container.Set[string]
OrgDisabledFeatures container.Set[string]
}
var validUserFeatures = container.SetOf(
@ -26,12 +27,21 @@ var validUserFeatures = container.SetOf(
UserFeatureChangeFullName,
)
var validOrgFeatures = container.SetOf(
OrgFeatureDangerZone,
)
func CanManageOrgDangerZone(isAdmin bool) bool {
return isAdmin || !Admin.OrgDisabledFeatures.Contains(OrgFeatureDangerZone)
}
func loadAdminFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("admin")
Admin.DisableRegularOrgCreation = sec.Key("DISABLE_REGULAR_ORG_CREATION").MustBool(false)
Admin.DefaultEmailNotification = sec.Key("DEFAULT_EMAIL_NOTIFICATIONS").MustString("enabled")
Admin.UserDisabledFeatures = container.SetOf(sec.Key("USER_DISABLED_FEATURES").Strings(",")...)
Admin.ExternalUserDisableFeatures = container.SetOf(sec.Key("EXTERNAL_USER_DISABLE_FEATURES").Strings(",")...).Union(Admin.UserDisabledFeatures)
Admin.OrgDisabledFeatures = container.SetOf(sec.Key("ORG_DISABLED_FEATURES").Strings(",")...)
for feature := range Admin.UserDisabledFeatures {
if !validUserFeatures.Contains(feature) {
@ -43,6 +53,11 @@ func loadAdminFrom(rootCfg ConfigProvider) {
log.Warn("EXTERNAL_USER_DISABLE_FEATURES contains unknown feature %q", feature)
}
}
for feature := range Admin.OrgDisabledFeatures {
if !validOrgFeatures.Contains(feature) {
log.Warn("ORG_DISABLED_FEATURES contains unknown feature %q", feature)
}
}
}
const (
@ -53,4 +68,6 @@ const (
UserFeatureManageCredentials = "manage_credentials"
UserFeatureChangeUsername = "change_username"
UserFeatureChangeFullName = "change_full_name"
OrgFeatureDangerZone = "danger_zone"
)

View File

@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"testing"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert"
)
func TestLoadAdminOrgDisabledFeatures(t *testing.T) {
defer test.MockVariableValue(&Admin)()
cfg, err := NewConfigProviderFromData(`
[admin]
ORG_DISABLED_FEATURES = danger_zone
`)
assert.NoError(t, err)
loadAdminFrom(cfg)
assert.True(t, Admin.OrgDisabledFeatures.Contains(OrgFeatureDangerZone))
assert.False(t, CanManageOrgDangerZone(false))
assert.True(t, CanManageOrgDangerZone(true))
}
func TestLoadAdminOrgDisabledFeaturesDefault(t *testing.T) {
defer test.MockVariableValue(&Admin)()
cfg, err := NewConfigProviderFromData(`
[admin]
`)
assert.NoError(t, err)
loadAdminFrom(cfg)
assert.False(t, Admin.OrgDisabledFeatures.Contains(OrgFeatureDangerZone))
assert.True(t, CanManageOrgDangerZone(false))
}

View File

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/models/perm"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
@ -340,6 +341,11 @@ func Rename(ctx *context.APIContext) {
// "422":
// "$ref": "#/responses/validationError"
if ctx.Doer == nil || !setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin) {
ctx.APIError(http.StatusForbidden, "Organization danger zone actions are restricted to site administrators")
return
}
form := web.GetForm(ctx).(*api.RenameOrgOption)
orgUser := ctx.Org.Organization.AsUser()
if err := user_service.RenameUser(ctx, orgUser, form.NewName, ctx.Doer); err != nil {
@ -380,6 +386,10 @@ func Edit(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.EditOrgOption)
if form.Visibility != nil && *form.Visibility != "" && ctx.Org.Organization.Visibility.String() != *form.Visibility && (ctx.Doer == nil || !setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin)) {
ctx.APIError(http.StatusForbidden, "Organization danger zone actions are restricted to site administrators")
return
}
if err := org.UpdateOrgEmailAddress(ctx, ctx.Org.Organization, form.Email); err != nil {
if errors.Is(err, util.ErrInvalidArgument) {
@ -425,6 +435,11 @@ func Delete(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"
if ctx.Doer == nil || !setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin) {
ctx.APIError(http.StatusForbidden, "Organization danger zone actions are restricted to site administrators")
return
}
if err := org.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil {
ctx.APIErrorInternal(err)
return

View File

@ -47,6 +47,7 @@ func Settings(ctx *context.Context) {
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
ctx.Data["RepoAdminChangeTeamAccess"] = ctx.Org.Organization.RepoAdminChangeTeamAccess
ctx.Data["ContextUser"] = ctx.ContextUser
ctx.Data["CanManageOrgDangerZone"] = setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin)
if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil {
ctx.ServerError("RenderUserOrgHeader", err)
@ -63,6 +64,7 @@ func SettingsPost(ctx *context.Context) {
ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsSettingsOptions"] = true
ctx.Data["CurrentVisibility"] = ctx.Org.Organization.Visibility
ctx.Data["CanManageOrgDangerZone"] = setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin)
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSettingsOptions)
@ -125,6 +127,11 @@ func SettingsDeleteAvatar(ctx *context.Context) {
// SettingsDeleteOrgPost response for deleting an organization
func SettingsDeleteOrgPost(ctx *context.Context) {
if !setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin) {
ctx.HTTPError(http.StatusForbidden)
return
}
if ctx.Org.Organization.Name != ctx.FormString("org_name") {
ctx.JSONError(ctx.Tr("form.enterred_invalid_org_name"))
return
@ -198,6 +205,11 @@ func Labels(ctx *context.Context) {
// SettingsRenamePost response for renaming organization
func SettingsRenamePost(ctx *context.Context) {
if !setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin) {
ctx.HTTPError(http.StatusForbidden)
return
}
form := web.GetForm(ctx).(*forms.RenameOrgForm)
if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
@ -248,6 +260,11 @@ func SettingsChangeVisibilityPost(ctx *context.Context) {
return
}
if !setting.CanManageOrgDangerZone(ctx.Doer.IsAdmin) {
ctx.HTTPError(http.StatusForbidden)
return
}
if err := org_service.ChangeOrganizationVisibility(ctx, ctx.Org.Organization, visibility); err != nil {
log.Error("ChangeOrganizationVisibility: %v", err)
ctx.JSONError(ctx.Tr("error.occurred"))

View File

@ -1,3 +1,4 @@
{{if .CanManageOrgDangerZone}}
<h4 class="ui top attached error header">
{{ctx.Locale.Tr "repo.settings.danger_zone"}}
</h4>
@ -140,3 +141,4 @@
</form>
</div>
</div>
{{end}}