From 49f88a4b9eec7db7ed94e103dda152e56d9d9554 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Thu, 28 May 2026 11:29:32 -0600 Subject: [PATCH] feat(repo): split repository creation limit into user and org scopes (#37872) ## Background `MAX_CREATION_LIMIT` applies to whoever owns a new repository, with no distinction between individual users and organizations. Admins who want different limits for the two - most commonly "block personal repos but let orgs create freely" - currently have to set per-user / per-org overrides on every entity. ## Changes Adds two new `[repository]` settings: - `USER_MAX_CREATION_LIMIT`: global limit for individual users - `ORG_MAX_CREATION_LIMIT`: global limit for organizations `MAX_CREATION_LIMIT` is kept as a shortcut: when set, it becomes the default value for both new keys. When the new keys are explicitly configured, they take precedence. Deployments that only set `MAX_CREATION_LIMIT` see behavior identical to now. Co-authored-by: Lunny Xiao --- custom/conf/app.example.ini | 11 ++++- models/user/user.go | 21 ++++----- models/user/user_test.go | 40 ++++++++++++++++- modules/setting/repository.go | 8 ++++ modules/setting/repository_test.go | 66 ++++++++++++++++++++++++++++ services/repository/transfer.go | 7 +-- services/repository/transfer_test.go | 2 + 7 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 modules/setting/repository_test.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 7619f46529..2793dd1ca0 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1024,9 +1024,18 @@ LEVEL = Info ;; Default private when using push-to-create ;DEFAULT_PUSH_CREATE_PRIVATE = true ;; -;; Global limit of repositories per user, applied at creation time. -1 means no limit +;; Global limit of repositories per user or org, applied at creation time. -1 means no limit +;; To configure independent limits for users and orgs, use USER_MAX_CREATION_LIMIT and ORG_MAX_CREATION_LIMIT ;MAX_CREATION_LIMIT = -1 ;; +;; Global limit of repositories per user, applied at creation time. -1 means no limit +;; Takes precedence over MAX_CREATION_LIMIT when set +;USER_MAX_CREATION_LIMIT = -1 +;; +;; Global limit of repositories per organization, applied at creation time. -1 means no limit +;; Takes precedence over MAX_CREATION_LIMIT when set +;ORG_MAX_CREATION_LIMIT = -1 +;; ;; Preferred Licenses to place at the top of the List ;; The name here must match the filename in options/license or custom/options/license ;PREFERRED_LICENSES = Apache License 2.0,MIT License diff --git a/models/user/user.go b/models/user/user.go index 5c7a7148e5..66e8d49b42 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -244,12 +244,15 @@ func (u *User) IsOAuth2() bool { return u.LoginType == auth.OAuth2 } -// MaxCreationLimit returns the number of repositories a user is allowed to create +// MaxCreationLimit returns the number of repositories a user or an organization is allowed to create func (u *User) MaxCreationLimit() int { - if u.MaxRepoCreation <= -1 { - return setting.Repository.MaxCreationLimit + if u.MaxRepoCreation > -1 { + return u.MaxRepoCreation } - return u.MaxRepoCreation + if u.IsOrganization() { + return setting.Repository.OrgMaxCreationLimit + } + return setting.Repository.UserMaxCreationLimit } // CanCreateRepoIn checks whether the doer(u) can create a repository in the owner @@ -264,13 +267,11 @@ func (u *User) CanCreateRepoIn(owner *User) bool { return true } const noLimit = -1 - if owner.MaxRepoCreation == noLimit { - if setting.Repository.MaxCreationLimit == noLimit { - return true - } - return owner.NumRepos < setting.Repository.MaxCreationLimit + limit := owner.MaxCreationLimit() + if limit == noLimit { + return true } - return owner.NumRepos < owner.MaxRepoCreation + return owner.NumRepos < limit } // CanCreateOrganization returns true if user can create organisation. diff --git a/models/user/user_test.go b/models/user/user_test.go index 47eb00881f..2bf32a5038 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -674,12 +674,18 @@ func TestGetInactiveUsers(t *testing.T) { func TestCanCreateRepo(t *testing.T) { defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)() + defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit)() + defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit)() const noLimit = -1 doerActions := user_model.NewActionsUser() doerNormal := &user_model.User{ID: 2} doerAdmin := &user_model.User{ID: 1, IsAdmin: true} + orgOwner := func(numRepos, maxRepoCreation int) *user_model.User { + return &user_model.User{ID: 3, Type: user_model.UserTypeOrganization, NumRepos: numRepos, MaxRepoCreation: maxRepoCreation} + } t.Run("NoGlobalLimit", func(t *testing.T) { - setting.Repository.MaxCreationLimit = noLimit + setting.Repository.UserMaxCreationLimit = noLimit + setting.Repository.OrgMaxCreationLimit = noLimit assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 0})) assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) @@ -693,7 +699,8 @@ func TestCanCreateRepo(t *testing.T) { }) t.Run("GlobalLimit50", func(t *testing.T) { - setting.Repository.MaxCreationLimit = 50 + setting.Repository.UserMaxCreationLimit = 50 + setting.Repository.OrgMaxCreationLimit = 50 assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: noLimit})) assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: noLimit})) // limited by global limit @@ -707,4 +714,33 @@ func TestCanCreateRepo(t *testing.T) { assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 10, MaxRepoCreation: 100})) assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, MaxRepoCreation: 100})) }) + + t.Run("UserBlockedOrgsUnlimited", func(t *testing.T) { + // User and org limits are independent: a deployment can block personal repos while leaving orgs unrestricted. + setting.Repository.UserMaxCreationLimit = 0 + setting.Repository.OrgMaxCreationLimit = noLimit + + // regular user is blocked + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 0, MaxRepoCreation: noLimit})) + // per-user override grants individual exceptions even when the global user limit is 0 + assert.True(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 3, MaxRepoCreation: 5})) + assert.False(t, doerNormal.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 5, MaxRepoCreation: 5})) + + // organization can create unlimited repos + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(10, noLimit))) + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(999, noLimit))) + // per-org override still wins over the global org limit + assert.False(t, doerNormal.CanCreateRepoIn(orgOwner(5, 5))) + }) + + t.Run("OrgGlobalLimitWithPerOrgOverride", func(t *testing.T) { + setting.Repository.UserMaxCreationLimit = noLimit + setting.Repository.OrgMaxCreationLimit = 10 + + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(5, noLimit))) + assert.False(t, doerNormal.CanCreateRepoIn(orgOwner(10, noLimit))) + + // per-org override bypasses the global org limit + assert.True(t, doerNormal.CanCreateRepoIn(orgOwner(10, 100))) + }) } diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 94da96a233..d1000c5280 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -37,6 +37,8 @@ var ( DefaultPrivate string DefaultPushCreatePrivate bool MaxCreationLimit int + UserMaxCreationLimit int + OrgMaxCreationLimit int PreferredLicenses []string DisableHTTPGit bool AccessControlAllowOrigin string @@ -165,6 +167,8 @@ var ( DefaultPrivate: RepoCreatingLastUserVisibility, DefaultPushCreatePrivate: true, MaxCreationLimit: -1, + UserMaxCreationLimit: -1, + OrgMaxCreationLimit: -1, PreferredLicenses: []string{"Apache License 2.0", "MIT License"}, DisableHTTPGit: false, AccessControlAllowOrigin: "", @@ -297,7 +301,11 @@ func loadRepositoryFrom(rootCfg ConfigProvider) { Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool() Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool() Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https") + // MAX_CREATION_LIMIT is a shortcut that sets the default for the two per-type limits below. + // USER_/ORG_MAX_CREATION_LIMIT take precedence when explicitly set. Repository.MaxCreationLimit = sec.Key("MAX_CREATION_LIMIT").MustInt(-1) + Repository.UserMaxCreationLimit = sec.Key("USER_MAX_CREATION_LIMIT").MustInt(Repository.MaxCreationLimit) + Repository.OrgMaxCreationLimit = sec.Key("ORG_MAX_CREATION_LIMIT").MustInt(Repository.MaxCreationLimit) Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch) RepoRootPath = sec.Key("ROOT").MustString(filepath.Join(AppDataPath, "gitea-repositories")) if !filepath.IsAbs(RepoRootPath) { diff --git a/modules/setting/repository_test.go b/modules/setting/repository_test.go new file mode 100644 index 0000000000..bd2779019c --- /dev/null +++ b/modules/setting/repository_test.go @@ -0,0 +1,66 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "gitea.dev/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestLoadRepositoryCreationLimits(t *testing.T) { + defer test.MockVariableValue(&Repository.MaxCreationLimit)() + defer test.MockVariableValue(&Repository.UserMaxCreationLimit)() + defer test.MockVariableValue(&Repository.OrgMaxCreationLimit)() + + t.Run("ShortcutPropagatesToBoth", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +MAX_CREATION_LIMIT = 5 +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, 5, Repository.MaxCreationLimit) + assert.Equal(t, 5, Repository.UserMaxCreationLimit) + assert.Equal(t, 5, Repository.OrgMaxCreationLimit) + }) + + t.Run("PerTypeKeysOverrideShortcut", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +MAX_CREATION_LIMIT = 5 +USER_MAX_CREATION_LIMIT = 0 +ORG_MAX_CREATION_LIMIT = -1 +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, 0, Repository.UserMaxCreationLimit) + assert.Equal(t, -1, Repository.OrgMaxCreationLimit) + }) + + t.Run("PartialOverrideOtherInheritsShortcut", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +MAX_CREATION_LIMIT = 7 +ORG_MAX_CREATION_LIMIT = -1 +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, 7, Repository.UserMaxCreationLimit) + assert.Equal(t, -1, Repository.OrgMaxCreationLimit) + }) + + t.Run("NoKeyDefaultsToNoLimit", func(t *testing.T) { + cfg, err := NewConfigProviderFromData(` +[repository] +`) + assert.NoError(t, err) + loadRepositoryFrom(cfg) + assert.Equal(t, -1, Repository.MaxCreationLimit) + assert.Equal(t, -1, Repository.UserMaxCreationLimit) + assert.Equal(t, -1, Repository.OrgMaxCreationLimit) + }) +} diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 5d4c4c9291..76051ac4f7 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -20,7 +20,6 @@ import ( "gitea.dev/modules/gitrepo" "gitea.dev/modules/globallock" "gitea.dev/modules/log" - "gitea.dev/modules/setting" "gitea.dev/modules/util" notify_service "gitea.dev/services/notify" ) @@ -62,8 +61,7 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d } if !doer.CanCreateRepoIn(repoTransfer.Recipient) { - limit := util.Iif(repoTransfer.Recipient.MaxRepoCreation >= 0, repoTransfer.Recipient.MaxRepoCreation, setting.Repository.MaxCreationLimit) - return LimitReachedError{Limit: limit} + return LimitReachedError{Limit: repoTransfer.Recipient.MaxCreationLimit()} } if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) { @@ -434,8 +432,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use } if !doer.CanForkRepoIn(newOwner) { - limit := util.Iif(newOwner.MaxRepoCreation >= 0, newOwner.MaxRepoCreation, setting.Repository.MaxCreationLimit) - return LimitReachedError{Limit: limit} + return LimitReachedError{Limit: newOwner.MaxCreationLimit()} } var isDirectTransfer bool diff --git a/services/repository/transfer_test.go b/services/repository/transfer_test.go index 99d2af109f..85282a1135 100644 --- a/services/repository/transfer_test.go +++ b/services/repository/transfer_test.go @@ -133,6 +133,8 @@ func TestRepositoryTransferRejection(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) // Set limit to 0 repositories so no repositories can be transferred defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)() + defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit, 0)() + defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit, 0)() // Admin case doerAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})