From 76bb60215bbbd198721f395bd5b67d4404b7948a Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 2 Apr 2026 21:59:52 -0700 Subject: [PATCH] Restrict organization danger zone actions to site admins via admin config --- custom/conf/app.example.ini | 3 ++ modules/setting/admin.go | 17 ++++++++ modules/setting/admin_test.go | 40 +++++++++++++++++++ routers/api/v1/org/org.go | 15 +++++++ routers/web/org/setting.go | 17 ++++++++ .../org/settings/options_dangerzone.tmpl | 2 + 6 files changed, 94 insertions(+) create mode 100644 modules/setting/admin_test.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 4df50f5cc6..337ec988a3 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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 = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/modules/setting/admin.go b/modules/setting/admin.go index 782c73f0cc..a5f0a743cb 100644 --- a/modules/setting/admin.go +++ b/modules/setting/admin.go @@ -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" ) diff --git a/modules/setting/admin_test.go b/modules/setting/admin_test.go new file mode 100644 index 0000000000..d147ddfa03 --- /dev/null +++ b/modules/setting/admin_test.go @@ -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)) +} diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index ce2a2e5580..0196fa34fa 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -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 diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index ca1da6617e..7b7f17d40f 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -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")) diff --git a/templates/org/settings/options_dangerzone.tmpl b/templates/org/settings/options_dangerzone.tmpl index df513c5525..41d71e86b8 100644 --- a/templates/org/settings/options_dangerzone.tmpl +++ b/templates/org/settings/options_dangerzone.tmpl @@ -1,3 +1,4 @@ +{{if .CanManageOrgDangerZone}}

{{ctx.Locale.Tr "repo.settings.danger_zone"}}

@@ -140,3 +141,4 @@ +{{end}}