mirror of
https://github.com/go-gitea/gitea
synced 2026-06-11 05:03:08 +00: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:
co-authored by
GitHub
Lunny Xiao
parent
52fef74291
commit
49f88a4b9e
@@ -1024,9 +1024,18 @@ LEVEL = Info
|
|||||||
;; Default private when using push-to-create
|
;; Default private when using push-to-create
|
||||||
;DEFAULT_PUSH_CREATE_PRIVATE = true
|
;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
|
;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
|
;; Preferred Licenses to place at the top of the List
|
||||||
;; The name here must match the filename in options/license or custom/options/license
|
;; The name here must match the filename in options/license or custom/options/license
|
||||||
;PREFERRED_LICENSES = Apache License 2.0,MIT License
|
;PREFERRED_LICENSES = Apache License 2.0,MIT License
|
||||||
|
|||||||
+11
-10
@@ -244,12 +244,15 @@ func (u *User) IsOAuth2() bool {
|
|||||||
return u.LoginType == auth.OAuth2
|
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 {
|
func (u *User) MaxCreationLimit() int {
|
||||||
if u.MaxRepoCreation <= -1 {
|
if u.MaxRepoCreation > -1 {
|
||||||
return setting.Repository.MaxCreationLimit
|
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
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
const noLimit = -1
|
const noLimit = -1
|
||||||
if owner.MaxRepoCreation == noLimit {
|
limit := owner.MaxCreationLimit()
|
||||||
if setting.Repository.MaxCreationLimit == noLimit {
|
if limit == noLimit {
|
||||||
return true
|
return true
|
||||||
}
|
|
||||||
return owner.NumRepos < setting.Repository.MaxCreationLimit
|
|
||||||
}
|
}
|
||||||
return owner.NumRepos < owner.MaxRepoCreation
|
return owner.NumRepos < limit
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanCreateOrganization returns true if user can create organisation.
|
// CanCreateOrganization returns true if user can create organisation.
|
||||||
|
|||||||
@@ -674,12 +674,18 @@ func TestGetInactiveUsers(t *testing.T) {
|
|||||||
|
|
||||||
func TestCanCreateRepo(t *testing.T) {
|
func TestCanCreateRepo(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)()
|
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit)()
|
||||||
const noLimit = -1
|
const noLimit = -1
|
||||||
doerActions := user_model.NewActionsUser()
|
doerActions := user_model.NewActionsUser()
|
||||||
doerNormal := &user_model.User{ID: 2}
|
doerNormal := &user_model.User{ID: 2}
|
||||||
doerAdmin := &user_model.User{ID: 1, IsAdmin: true}
|
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) {
|
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.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}))
|
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) {
|
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.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
|
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: 10, MaxRepoCreation: 100}))
|
||||||
assert.True(t, doerAdmin.CanCreateRepoIn(&user_model.User{ID: 2, NumRepos: 60, 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
|
DefaultPrivate string
|
||||||
DefaultPushCreatePrivate bool
|
DefaultPushCreatePrivate bool
|
||||||
MaxCreationLimit int
|
MaxCreationLimit int
|
||||||
|
UserMaxCreationLimit int
|
||||||
|
OrgMaxCreationLimit int
|
||||||
PreferredLicenses []string
|
PreferredLicenses []string
|
||||||
DisableHTTPGit bool
|
DisableHTTPGit bool
|
||||||
AccessControlAllowOrigin string
|
AccessControlAllowOrigin string
|
||||||
@@ -165,6 +167,8 @@ var (
|
|||||||
DefaultPrivate: RepoCreatingLastUserVisibility,
|
DefaultPrivate: RepoCreatingLastUserVisibility,
|
||||||
DefaultPushCreatePrivate: true,
|
DefaultPushCreatePrivate: true,
|
||||||
MaxCreationLimit: -1,
|
MaxCreationLimit: -1,
|
||||||
|
UserMaxCreationLimit: -1,
|
||||||
|
OrgMaxCreationLimit: -1,
|
||||||
PreferredLicenses: []string{"Apache License 2.0", "MIT License"},
|
PreferredLicenses: []string{"Apache License 2.0", "MIT License"},
|
||||||
DisableHTTPGit: false,
|
DisableHTTPGit: false,
|
||||||
AccessControlAllowOrigin: "",
|
AccessControlAllowOrigin: "",
|
||||||
@@ -297,7 +301,11 @@ func loadRepositoryFrom(rootCfg ConfigProvider) {
|
|||||||
Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
|
Repository.DisableHTTPGit = sec.Key("DISABLE_HTTP_GIT").MustBool()
|
||||||
Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
|
Repository.UseCompatSSHURI = sec.Key("USE_COMPAT_SSH_URI").MustBool()
|
||||||
Repository.GoGetCloneURLProtocol = sec.Key("GO_GET_CLONE_URL_PROTOCOL").MustString("https")
|
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.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)
|
Repository.DefaultBranch = sec.Key("DEFAULT_BRANCH").MustString(Repository.DefaultBranch)
|
||||||
RepoRootPath = sec.Key("ROOT").MustString(filepath.Join(AppDataPath, "gitea-repositories"))
|
RepoRootPath = sec.Key("ROOT").MustString(filepath.Join(AppDataPath, "gitea-repositories"))
|
||||||
if !filepath.IsAbs(RepoRootPath) {
|
if !filepath.IsAbs(RepoRootPath) {
|
||||||
|
|||||||
@@ -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/gitrepo"
|
||||||
"gitea.dev/modules/globallock"
|
"gitea.dev/modules/globallock"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/setting"
|
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
notify_service "gitea.dev/services/notify"
|
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) {
|
if !doer.CanCreateRepoIn(repoTransfer.Recipient) {
|
||||||
limit := util.Iif(repoTransfer.Recipient.MaxRepoCreation >= 0, repoTransfer.Recipient.MaxRepoCreation, setting.Repository.MaxCreationLimit)
|
return LimitReachedError{Limit: repoTransfer.Recipient.MaxCreationLimit()}
|
||||||
return LimitReachedError{Limit: limit}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) {
|
if !repoTransfer.CanUserAcceptOrRejectTransfer(ctx, doer) {
|
||||||
@@ -434,8 +432,7 @@ func StartRepositoryTransfer(ctx context.Context, doer, newOwner *user_model.Use
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !doer.CanForkRepoIn(newOwner) {
|
if !doer.CanForkRepoIn(newOwner) {
|
||||||
limit := util.Iif(newOwner.MaxRepoCreation >= 0, newOwner.MaxRepoCreation, setting.Repository.MaxCreationLimit)
|
return LimitReachedError{Limit: newOwner.MaxCreationLimit()}
|
||||||
return LimitReachedError{Limit: limit}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isDirectTransfer bool
|
var isDirectTransfer bool
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ func TestRepositoryTransferRejection(t *testing.T) {
|
|||||||
require.NoError(t, unittest.PrepareTestDatabase())
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
// Set limit to 0 repositories so no repositories can be transferred
|
// Set limit to 0 repositories so no repositories can be transferred
|
||||||
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)()
|
defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.UserMaxCreationLimit, 0)()
|
||||||
|
defer test.MockVariableValue(&setting.Repository.OrgMaxCreationLimit, 0)()
|
||||||
|
|
||||||
// Admin case
|
// Admin case
|
||||||
doerAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
doerAdmin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
|||||||
Reference in New Issue
Block a user