mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-29 17:36:06 +02:00
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 <xiaolunwen@gmail.com>
This commit is contained in:
parent
52fef74291
commit
49f88a4b9e
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)))
|
||||
})
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
66
modules/setting/repository_test.go
Normal file
66
modules/setting/repository_test.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user