Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 =

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
17 changes: 17 additions & 0 deletions modules/setting/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -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 (
Expand All @@ -53,4 +68,6 @@ const (
UserFeatureManageCredentials = "manage_credentials"
UserFeatureChangeUsername = "change_username"
UserFeatureChangeFullName = "change_full_name"

OrgFeatureDangerZone = "danger_zone"
)
40 changes: 40 additions & 0 deletions modules/setting/admin_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
18 changes: 17 additions & 1 deletion routers/api/v1/org/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -340,6 +341,11 @@ func Rename(ctx *context.APIContext) {
// "422":
// "$ref": "#/responses/validationError"

if !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 {
Expand Down Expand Up @@ -380,6 +386,11 @@ func Edit(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"

form := web.GetForm(ctx).(*api.EditOrgOption)
visibility := optional.FromMapLookup(api.VisibilityModes, optional.FromPtr(form.Visibility).Value())
if visibility.Has() && visibility.Value() != ctx.Org.Organization.Visibility && !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) {
Expand All @@ -395,7 +406,7 @@ func Edit(ctx *context.APIContext) {
Description: optional.FromPtr(form.Description),
Website: optional.FromPtr(form.Website),
Location: optional.FromPtr(form.Location),
Visibility: optional.FromMapLookup(api.VisibilityModes, optional.FromPtr(form.Visibility).Value()),
Visibility: visibility,
RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess),
}
if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil {
Expand Down Expand Up @@ -425,6 +436,11 @@ func Delete(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

if !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
Expand Down
17 changes: 17 additions & 0 deletions routers/web/org/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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"))
Expand Down
2 changes: 2 additions & 0 deletions templates/org/settings/options_dangerzone.tmpl
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{if .CanManageOrgDangerZone}}
<h4 class="ui top attached error header">
{{ctx.Locale.Tr "repo.settings.danger_zone"}}
</h4>
Expand Down Expand Up @@ -140,3 +141,4 @@
</form>
</div>
</div>
{{end}}