Commit aaf2234b85

aaf2234b850d4bca9766084e3c80f8806d0bcc80

parent: 76385afe09

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-02 15:13 UTC

control: quotas for organizations

limits.max_orgs_per_user caps the organizations an account creates,
counted by orgs.created_by (backfilled from the first admin). Membership
in someone else's org does not count. limits.max_repos_per_org and
max_bytes_per_org cap each org the way the per-user limits cap an
account: repo create, fork, import, transfer, push, commit-file and
apply-suggestion. admin org limits sets per-org overrides;
admin user limits takes --orgs.

repo transfer now applies the receiving owner's caps, user or org.

Closes #325

Layout: unified · split

.gitbay/wiki/Admin.org +10 −4
@@ -358,10 +358,15 @@ push=.
358358- =max_snippet_bytes= (1MB) — cap per snippet file.
359359- =max_snippets_per_user= (0, unlimited) — snippets an account may own.
360360- =max_repos_per_user= (0, unlimited) — repositories an account may own
361 directly; =repo create=, =fork= and =import= refuse past it.
362 Organizations are not capped.
361 directly; =repo create=, =fork=, =import= and =repo transfer= refuse
362 past it.
363363- =max_bytes_per_user= (0, unlimited) — disk the account's own
364 repositories may take; a push may be no larger than what is left.
364 repositories may take; a push may be no larger than what is left, and
365 a transfer in must fit.
366- =max_orgs_per_user= (0, unlimited) — organizations an account may
367 create. Membership in an org someone else created does not count.
368- =max_repos_per_org=, =max_bytes_per_org= (0, unlimited) — the same two
369 caps for each organization.
365370- =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32),
366371 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches,
367372 =git archive --remote=, web archive downloads, =repo download= over
@@ -520,7 +525,8 @@ gitbayd admin audit verify # check the hash chain; exit 1 names the fi
520525ssh git@<host> audit ... # the same, from an admin session
521526ssh git@<host> admin user list [--state active|pending|disabled|admin]
522527ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions
523ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] # per-account caps
528ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] [--orgs n|default] # per-account caps
529ssh git@<host> admin org limits <org> [--repos n|default] [--bytes n|default] # per-org caps
524530ssh git@<host> admin user promote <name> # grant instance admin
525531ssh git@<host> admin user demote <name> # remove it; the last admin is refused
526532gitbayd admin user promote <name> # host-local: recovery when no admin key is reachable
.gitbay/wiki/Parity.org +1
@@ -478,6 +478,7 @@ when there is none.
478478| disable, enable | yes | yes | no |
479479| account create, delete | yes | no | no |
480480| invite | yes | no | no |
481| account and org quotas | yes | no | no |
481482| worker queues | yes | yes | no |
482483| runners | yes | no | no |
483484| repository list, archive, visibility | yes | no | no |
cmd/gitbay/main.go +3
@@ -129,6 +129,9 @@ func newRoot() *cobra.Command {
129129 pass("delete", passOpts{server: []string{"admin", "user", "delete"}}),
130130 pass("limits", passOpts{server: []string{"admin", "user", "limits"}}),
131131 ),
132 group("org", "any organization",
133 pass("limits", passOpts{server: []string{"admin", "org", "limits"}}),
134 ),
132135 group("email", "addresses on any account",
133136 pass("verify", passOpts{server: []string{"admin", "email", "verify"}}),
134137 ),
cmd/gitbay/summaries_gen.go +2 −1
@@ -9,6 +9,7 @@ var summaries = map[string]string{
99 "admin invite": "issue a registration invite and mail its code",
1010 "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting",
1111 "admin mr prune": "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
12 "admin org limits": "show or set an organization's repository and storage caps (instance admins)",
1213 "admin repo archive": "archive any repository (instance admins; audited)",
1314 "admin repo delete": "delete any repository (instance admins; audited)",
1415 "admin repo list": "list every repository with size and last push (instance admins)",
@@ -24,7 +25,7 @@ var summaries = map[string]string{
2425 "admin user demote": "remove instance admin from an account (never the last one)",
2526 "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
2627 "admin user enable": "restore a suspended account",
27 "admin user limits": "show or set an account's repository and storage caps (instance admins)",
28 "admin user limits": "show or set an account's repository, storage and organization caps (instance admins)",
2829 "admin user list": "list accounts (instance admins)",
2930 "admin user promote": "make an account an instance admin",
3031 "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
e2e/quota_test.go +32 −4
@@ -10,14 +10,16 @@ import (
1010 "time"
1111)
1212
13// Per-account caps on repositories and storage, with the admin override,
14// and expiry of accounts that never verified.
13// Per-account and per-org caps on repositories and storage, the cap on
14// orgs an account creates, the admin overrides, and expiry of accounts
15// that never verified.
1516func TestQuotasAndPendingExpiry(t *testing.T) {
1617 t.Setenv("GITBAY_REAP_TICK", "500ms")
1718 smtp := startFakeSMTP(t)
1819 inst := startInstanceWith(t, fmt.Sprintf(
1920 "[registration]\nmode = \"open\"\npending_expiry = \"2s\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n"+
20 "[limits]\nmax_repos_per_user = 2\nmax_bytes_per_user = 300000\n", smtp.addr))
21 "[limits]\nmax_repos_per_user = 2\nmax_bytes_per_user = 300000\n"+
22 "max_orgs_per_user = 1\nmax_repos_per_org = 1\n", smtp.addr))
2123 rootKey := inst.newKey(t, "root")
2224 aliceKey := inst.newKey(t, "alice")
2325 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
@@ -35,13 +37,32 @@ func TestQuotasAndPendingExpiry(t *testing.T) {
3537 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "fork", "alice/one", "--name", "onefork"); code != 4 {
3638 t.Fatal("fork slipped past the cap")
3739 }
38 // An org is not capped.
40 // One org fits; the second is refused.
3941 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
4042 t.Fatal("org create failed")
4143 }
44 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme2"); code != 4 || !strings.Contains(errOut, "1 of the 1 organizations") {
45 t.Fatalf("second org: exit %d %s", code, errOut)
46 }
47 // The org has its own repository cap, which the admin raises per org.
4248 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib"); code != 0 {
4349 t.Fatalf("org repo: %s", errOut)
4450 }
51 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib2"); code != 4 || !strings.Contains(errOut, "1 of the 1 repositories") {
52 t.Fatalf("second org repo: exit %d %s", code, errOut)
53 }
54 if out, _, code := inst.ssh(t, rootKey, "", "admin", "org", "limits", "acme", "--repos", "2"); code != 0 || !strings.Contains(out, "repos 1 of 2") {
55 t.Fatalf("org limits: exit %d %s", code, out)
56 }
57 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib2"); code != 0 {
58 t.Fatalf("second org repo after raise: %s", errOut)
59 }
60 if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--orgs", "2"); code != 0 || !strings.Contains(out, "orgs 1 of 2") {
61 t.Fatalf("user org limit: exit %d %s", code, out)
62 }
63 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "acme2"); code != 0 {
64 t.Fatalf("second org after raise: %s", errOut)
65 }
4566 // The admin raises the cap for this account; the third fits.
4667 if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "3"); code != 0 || !strings.Contains(out, "repos 2 of 3") {
4768 t.Fatalf("limits: exit %d %s", code, out)
@@ -55,6 +76,13 @@ func TestQuotasAndPendingExpiry(t *testing.T) {
5576 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "default"); !strings.Contains(out, "of 2") {
5677 t.Fatalf("limits back to default:\n%s", out)
5778 }
79 // A transfer in counts as a create for the receiving owner.
80 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "acme/lib2", "alice"); code != 4 || !strings.Contains(errOut, "3 of the 2 repositories") {
81 t.Fatalf("transfer past the cap: exit %d %s", code, errOut)
82 }
83 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/three", "acme2"); code != 0 {
84 t.Fatalf("transfer into an org with room: %s", errOut)
85 }
5886
5987 // Storage: a push past what the account has left is refused.
6088 work := t.TempDir()
internal/config/config.go +9 −5
@@ -239,11 +239,14 @@ type Limits struct {
239239 // counted in the dispatcher so every surface shares one budget. 0 uses
240240 // the default; a negative value turns the limit off.
241241 WriteRate int `toml:"write_rate"`
242 // Per-account quotas on what a user owns directly (organizations are
243 // not capped). 0 means unlimited; admin user limits overrides per
244 // account.
242 // Quotas on what a user or an org owns directly, and on the orgs an
243 // account creates. 0 means unlimited; admin user limits and admin org
244 // limits override per owner.
245245 MaxReposPerUser int `toml:"max_repos_per_user"`
246246 MaxBytesPerUser int64 `toml:"max_bytes_per_user"`
247 MaxOrgsPerUser int `toml:"max_orgs_per_user"`
248 MaxReposPerOrg int `toml:"max_repos_per_org"`
249 MaxBytesPerOrg int64 `toml:"max_bytes_per_org"`
247250 // PackConcurrency caps git pack generation (upload-pack and
248251 // upload-archive) running at once across SSH, smart HTTP and git://.
249252 // PackPerPrincipal caps it per account, or per client address on the
@@ -675,8 +678,9 @@ func (c Config) Validate() error {
675678 errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry))
676679 }
677680 }
678 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 {
679 errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user and max_snippets_per_user must not be negative"))
681 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 ||
682 c.Limits.MaxOrgsPerUser < 0 || c.Limits.MaxReposPerOrg < 0 || c.Limits.MaxBytesPerOrg < 0 {
683 errs = append(errs, errors.New("limits.max_repos_per_user, max_bytes_per_user, max_snippets_per_user, max_orgs_per_user, max_repos_per_org and max_bytes_per_org must not be negative"))
680684 }
681685 for _, w := range []struct{ name, val string }{
682686 {"pack_queue_wait", c.Limits.PackQueueWait},
internal/control/admin.go +3 −3
@@ -283,11 +283,11 @@ func runAdminUserShow(c *Ctx, args []string) int {
283283 for _, m := range orgs {
284284 d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
285285 }
286 if d.Repos, err = c.Store.OwnedRepoCount(u.ID); err != nil {
286 if d.Repos, err = c.Store.OwnedRepoCount("user", u.ID); err != nil {
287287 return c.fail(protocol.ExitFailure, "%v", err)
288288 }
289 d.RepoLimit = RepoLimit(c.Store, limitsOf(c), u.ID)
290 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), u.ID)
289 d.RepoLimit = RepoLimit(c.Store, limitsOf(c), "user", u.ID)
290 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), "user", u.ID)
291291 tokens, err := c.Store.ListAPITokens(u.ID)
292292 if err != nil {
293293 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/import.go +5 −9
@@ -76,10 +76,8 @@ func runRepoImport(c *Ctx, args []string) int {
7676 }
7777 ownerKind, ownerID = "org", org.ID
7878 }
79 if ownerKind == "user" {
80 if code := checkRepoQuota(c); code >= 0 {
81 return code
82 }
79 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
80 return code
8381 }
8482
8583 // http and https only. git:// has no equivalent of curl's resolve
@@ -138,11 +136,9 @@ func runRepoImport(c *Ctx, args []string) int {
138136 // The early check above fails fast; this one holds the lock across
139137 // the insert so a concurrent create cannot slip past the count.
140138 repoCreateMu.Lock()
141 if ownerKind == "user" {
142 if code := checkRepoQuota(c); code >= 0 {
143 repoCreateMu.Unlock()
144 return code
145 }
139 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
140 repoCreateMu.Unlock()
141 return code
146142 }
147143 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
148144 repoCreateMu.Unlock()
internal/control/mr.go +3 −7
@@ -244,13 +244,9 @@ func runRepoFork(c *Ctx, args []string) int {
244244 return code
245245 }
246246 repoCreateMu.Lock()
247 // An organization's repositories are not counted against the quota,
248 // the same as repo create.
249 if ownerKind == "user" {
250 if code := checkRepoQuota(c); code >= 0 {
251 repoCreateMu.Unlock()
252 return code
253 }
247 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
248 repoCreateMu.Unlock()
249 return code
254250 }
255251 id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID)
256252 repoCreateMu.Unlock()
internal/control/org.go +8 −1
@@ -80,7 +80,14 @@ func runOrgCreate(c *Ctx, args []string) int {
8080 if err := policy.ValidateOwnerName(args[0]); err != nil {
8181 return c.failInput(err)
8282 }
83 if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil {
83 orgCreateMu.Lock()
84 if code := checkOrgQuota(c); code >= 0 {
85 orgCreateMu.Unlock()
86 return code
87 }
88 _, err := c.Store.CreateOrg(args[0], c.User.ID)
89 orgCreateMu.Unlock()
90 if err != nil {
8491 return c.fail(protocol.ExitFailure, "%v", err)
8592 }
8693 return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) {
internal/control/quota.go +180 −65
@@ -12,28 +12,43 @@ import (
1212 "gitbay.org/gitbay/internal/store"
1313)
1414
15// Quotas cap what one account owns directly. The limit is the account's
16// override when set, else the configured default; 0 is unlimited.
15// Quotas cap what a user or an org owns directly, and how many orgs an
16// account creates. The limit is the owner's override when set, else the
17// configured default; 0 is unlimited.
1718
18// RepoLimit is the account's repository cap, 0 for none.
19func RepoLimit(st *store.Store, cfg configLimits, userID int64) int64 {
20 if l, err := st.UserLimits(userID); err == nil && l.Repos != nil {
19// RepoLimit is the owner's repository cap, 0 for none.
20func RepoLimit(st *store.Store, cfg configLimits, kind string, id int64) int64 {
21 if l, err := st.OwnerLimits(kind, id); err == nil && l.Repos != nil {
2122 return *l.Repos
2223 }
24 if kind == "org" {
25 return int64(cfg.MaxReposPerOrg)
26 }
2327 return int64(cfg.MaxReposPerUser)
2428}
2529
26// ByteLimit is the account's storage cap in bytes, 0 for none.
27func ByteLimit(st *store.Store, cfg configLimits, userID int64) int64 {
28 if l, err := st.UserLimits(userID); err == nil && l.Bytes != nil {
30// ByteLimit is the owner's storage cap in bytes, 0 for none.
31func ByteLimit(st *store.Store, cfg configLimits, kind string, id int64) int64 {
32 if l, err := st.OwnerLimits(kind, id); err == nil && l.Bytes != nil {
2933 return *l.Bytes
3034 }
35 if kind == "org" {
36 return cfg.MaxBytesPerOrg
37 }
3138 return cfg.MaxBytesPerUser
3239}
3340
34// OwnedBytes is the disk taken by the repositories a user owns directly.
35func OwnedBytes(st *store.Store, root string, userID int64) int64 {
36 repos, err := st.ListReposForOwner("user", userID)
41// OrgLimit is the account's cap on organizations it creates, 0 for none.
42func OrgLimit(st *store.Store, cfg configLimits, userID int64) int64 {
43 if l, err := st.OwnerLimits("user", userID); err == nil && l.Orgs != nil {
44 return *l.Orgs
45 }
46 return int64(cfg.MaxOrgsPerUser)
47}
48
49// OwnedBytes is the disk taken by the repositories an owner holds directly.
50func OwnedBytes(st *store.Store, root, kind string, id int64) int64 {
51 repos, err := st.ListReposForOwner(kind, id)
3752 if err != nil {
3853 return 0
3954 }
@@ -49,33 +64,38 @@ func OwnedBytes(st *store.Store, root string, userID int64) int64 {
4964type configLimits struct {
5065 MaxReposPerUser int
5166 MaxBytesPerUser int64
67 MaxOrgsPerUser int
68 MaxReposPerOrg int
69 MaxBytesPerOrg int64
5270}
5371
5472// QuotaConfig is what sshd passes: the limits section of the config.
5573func QuotaConfig(cfg config.Config) configLimits {
56 return configLimits{cfg.Limits.MaxReposPerUser, cfg.Limits.MaxBytesPerUser}
74 l := cfg.Limits
75 return configLimits{l.MaxReposPerUser, l.MaxBytesPerUser, l.MaxOrgsPerUser, l.MaxReposPerOrg, l.MaxBytesPerOrg}
5776}
5877
59func limitsOf(c *Ctx) configLimits {
60 return configLimits{c.Cfg.Limits.MaxReposPerUser, c.Cfg.Limits.MaxBytesPerUser}
61}
78func limitsOf(c *Ctx) configLimits { return QuotaConfig(c.Cfg) }
6279
63// checkRepoQuota refuses a new user-owned repository past the cap.
80// checkRepoQuota refuses one more repository for the owner past its cap.
6481// repoCreateMu serialises the quota check with the insert that follows
6582// it, so two concurrent creates cannot both pass the count (#108). One
6683// process serves the instance, so a process-wide lock is the whole story.
6784var repoCreateMu sync.Mutex
6885
69func checkRepoQuota(c *Ctx) int {
70 limit := RepoLimit(c.Store, limitsOf(c), c.User.ID)
86func checkRepoQuota(c *Ctx, kind string, id int64) int {
87 limit := RepoLimit(c.Store, limitsOf(c), kind, id)
7188 if limit == 0 {
7289 return -1
7390 }
74 n, err := c.Store.OwnedRepoCount(c.User.ID)
91 n, err := c.Store.OwnedRepoCount(kind, id)
7592 if err != nil {
7693 return c.fail(protocol.ExitFailure, "%v", err)
7794 }
7895 if n >= limit {
96 if kind == "org" {
97 return c.fail(protocol.ExitDenied, "the organization owns %d of the %d repositories it may hold; delete or transfer one, or ask an admin to raise the limit", n, limit)
98 }
7999 return c.fail(protocol.ExitDenied, "you own %d of the %d repositories your account may hold; delete or transfer one, or ask an admin to raise the limit", n, limit)
80100 }
81101 return -1
@@ -83,102 +103,197 @@ func checkRepoQuota(c *Ctx) int {
83103
84104// checkStorageQuota refuses a server-side write into repo once its
85105// owner's storage quota is used up, the check sshd makes before a push.
86// Repositories an org owns have no quota.
87106func checkStorageQuota(c *Ctx, repo store.Repo) int {
88 if repo.OwnerKind != "user" {
89 return -1
90 }
91 limit := ByteLimit(c.Store, limitsOf(c), repo.OwnerID)
107 return checkBytesLeft(c, repo.OwnerKind, repo.OwnerID, repo.OwnerName, 0)
108}
109
110// checkBytesLeft refuses when the owner's storage plus adding exceeds
111// its cap (with adding 0, once the cap is used up).
112func checkBytesLeft(c *Ctx, kind string, id int64, name string, adding int64) int {
113 limit := ByteLimit(c.Store, limitsOf(c), kind, id)
92114 if limit <= 0 {
93115 return -1
94116 }
95 if used := OwnedBytes(c.Store, c.Cfg.Server.Root, repo.OwnerID); used >= limit {
117 used := OwnedBytes(c.Store, c.Cfg.Server.Root, kind, id)
118 if adding == 0 && used >= limit {
96119 return c.fail(protocol.ExitDenied,
97120 "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit",
98 repo.OwnerName, used, limit)
121 name, used, limit)
122 }
123 if adding > 0 && used+adding > limit {
124 return c.fail(protocol.ExitDenied,
125 "%s's storage quota cannot take %d more bytes (%d of %d used); delete something, or ask an admin to raise the limit",
126 name, adding, used, limit)
127 }
128 return -1
129}
130
131// orgCreateMu serialises the org cap check with the insert, as
132// repoCreateMu does for repositories.
133var orgCreateMu sync.Mutex
134
135func checkOrgQuota(c *Ctx) int {
136 limit := OrgLimit(c.Store, limitsOf(c), c.User.ID)
137 if limit == 0 {
138 return -1
139 }
140 n, err := c.Store.CreatedOrgCount(c.User.ID)
141 if err != nil {
142 return c.fail(protocol.ExitFailure, "%v", err)
143 }
144 if n >= limit {
145 return c.fail(protocol.ExitDenied, "you have created %d of the %d organizations your account may create; delete one, or ask an admin to raise the limit", n, limit)
99146 }
100147 return -1
101148}
102149
103150func init() {
104151 register(Command{Path: []string{"admin", "user", "limits"},
105 Summary: "show or set an account's repository and storage caps (instance admins)",
106 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]",
152 Summary: "show or set an account's repository, storage and organization caps (instance admins)",
153 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default] [--orgs <n>|default]",
107154 Flags: []Flag{
108155 {"--repos", "<n>|default", "the account's repository cap", ""},
109156 {"--bytes", "<n>|default", "the account's storage cap", ""},
157 {"--orgs", "<n>|default", "the account's cap on organizations it creates", ""},
110158 },
111159 Examples: []string{"admin user limits alice", "admin user limits alice --repos 50"},
112160 Run: runAdminUserLimits})
161 register(Command{Path: []string{"admin", "org", "limits"},
162 Summary: "show or set an organization's repository and storage caps (instance admins)",
163 Usage: "admin org limits <org> [--repos <n>|default] [--bytes <n>|default]",
164 Flags: []Flag{
165 {"--repos", "<n>|default", "the organization's repository cap", ""},
166 {"--bytes", "<n>|default", "the organization's storage cap", ""},
167 },
168 Examples: []string{"admin org limits krz", "admin org limits krz --bytes 0"},
169 Run: runAdminOrgLimits})
113170}
114171
115func runAdminUserLimits(c *Ctx, args []string) int {
116 if code := requireInstanceAdmin(c); code >= 0 {
117 return code
118 }
119 if len(args) < 1 {
120 return c.usage()
121 }
122 u, err := c.Store.UserByUsername(args[0])
123 if err != nil {
124 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
125 }
126 l, err := c.Store.UserLimits(u.ID)
127 if err != nil {
128 return c.fail(protocol.ExitFailure, "%v", err)
129 }
130 set := false
131 for i := 1; i < len(args); i++ {
172// applyLimitFlags reads --repos/--bytes (and --orgs when orgs is true)
173// into l. set reports whether any flag was given.
174func applyLimitFlags(c *Ctx, args []string, l *store.Limits, orgs bool) (set bool, code int) {
175 for i := 0; i < len(args); i++ {
132176 if i+1 >= len(args) {
133 return c.fail(protocol.ExitUsage, "%s requires a value", args[i])
177 return false, c.fail(protocol.ExitUsage, "%s requires a value", args[i])
134178 }
135179 v := args[i+1]
136180 var target **int64
137 switch args[i] {
138 case "--repos":
181 switch {
182 case args[i] == "--repos":
139183 target = &l.Repos
140 case "--bytes":
184 case args[i] == "--bytes":
141185 target = &l.Bytes
186 case args[i] == "--orgs" && orgs:
187 target = &l.Orgs
142188 default:
143 return c.usage()
189 return false, c.usage()
144190 }
145191 if v == "default" {
146192 *target = nil
147193 } else {
148194 n, err := strconv.ParseInt(v, 10, 64)
149195 if err != nil || n < 0 {
150 return c.fail(protocol.ExitUsage, "%s takes a non-negative number or default", args[i])
196 return false, c.fail(protocol.ExitUsage, "%s takes a non-negative number or default", args[i])
151197 }
152198 *target = &n
153199 }
154200 set = true
155201 i++
156202 }
203 return set, -1
204}
205
206func capText(n int64) string {
207 if n == 0 {
208 return "unlimited"
209 }
210 return strconv.FormatInt(n, 10)
211}
212
213func runAdminUserLimits(c *Ctx, args []string) int {
214 if code := requireInstanceAdmin(c); code >= 0 {
215 return code
216 }
217 if len(args) < 1 {
218 return c.usage()
219 }
220 u, err := c.Store.UserByUsername(args[0])
221 if err != nil {
222 return c.fail(protocol.ExitNotFound, "no user %q", args[0])
223 }
224 l, err := c.Store.OwnerLimits("user", u.ID)
225 if err != nil {
226 return c.fail(protocol.ExitFailure, "%v", err)
227 }
228 set, code := applyLimitFlags(c, args[1:], &l, true)
229 if code >= 0 {
230 return code
231 }
157232 if set {
158 if err := c.Store.SetUserLimits(u.ID, l); err != nil {
233 if err := c.Store.SetOwnerLimits("user", u.ID, l); err != nil {
159234 return c.fail(protocol.ExitFailure, "%v", err)
160235 }
161 c.Store.Audit(c.User.ID, "admin user.limits", map[string]any{"user": u.Username, "repos": l.Repos, "bytes": l.Bytes})
236 c.Store.Audit(c.User.ID, "admin user.limits", map[string]any{"user": u.Username, "repos": l.Repos, "bytes": l.Bytes, "orgs": l.Orgs})
162237 }
163238 type out struct {
164 User string `json:"user"`
239 User string `json:"user"`
240 Repos int64 `json:"repos"` // effective cap, 0 unlimited
241 Bytes int64 `json:"bytes"` // effective cap, 0 unlimited
242 Orgs int64 `json:"orgs"` // effective cap, 0 unlimited
243 ReposOwned int64 `json:"repos_owned"`
244 BytesOwned int64 `json:"bytes_owned"`
245 OrgsCreated int64 `json:"orgs_created"`
246 Override bool `json:"override"` // any per-account value set
247 }
248 d := out{User: u.Username, Repos: RepoLimit(c.Store, limitsOf(c), "user", u.ID), Bytes: ByteLimit(c.Store, limitsOf(c), "user", u.ID),
249 Orgs: OrgLimit(c.Store, limitsOf(c), u.ID), Override: l.Repos != nil || l.Bytes != nil || l.Orgs != nil}
250 d.ReposOwned, _ = c.Store.OwnedRepoCount("user", u.ID)
251 d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, "user", u.ID)
252 d.OrgsCreated, _ = c.Store.CreatedOrgCount(u.ID)
253 return c.emit(d, func(w io.Writer) {
254 fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\torgs %d of %s\n", d.User,
255 d.ReposOwned, capText(d.Repos), d.BytesOwned, capText(d.Bytes), d.OrgsCreated, capText(d.Orgs))
256 })
257}
258
259func runAdminOrgLimits(c *Ctx, args []string) int {
260 if code := requireInstanceAdmin(c); code >= 0 {
261 return code
262 }
263 if len(args) < 1 {
264 return c.usage()
265 }
266 org, err := c.Store.OrgByName(args[0])
267 if err != nil {
268 return c.fail(protocol.ExitNotFound, "no organization %q", args[0])
269 }
270 l, err := c.Store.OwnerLimits("org", org.ID)
271 if err != nil {
272 return c.fail(protocol.ExitFailure, "%v", err)
273 }
274 set, code := applyLimitFlags(c, args[1:], &l, false)
275 if code >= 0 {
276 return code
277 }
278 if set {
279 if err := c.Store.SetOwnerLimits("org", org.ID, l); err != nil {
280 return c.fail(protocol.ExitFailure, "%v", err)
281 }
282 c.Store.Audit(c.User.ID, "admin org.limits", map[string]any{"org": org.Name, "repos": l.Repos, "bytes": l.Bytes})
283 }
284 type out struct {
285 Org string `json:"org"`
165286 Repos int64 `json:"repos"` // effective cap, 0 unlimited
166287 Bytes int64 `json:"bytes"` // effective cap, 0 unlimited
167288 ReposOwned int64 `json:"repos_owned"`
168289 BytesOwned int64 `json:"bytes_owned"`
169 Override bool `json:"override"` // any per-account value set
290 Override bool `json:"override"` // any per-org value set
170291 }
171 d := out{User: u.Username, Repos: RepoLimit(c.Store, limitsOf(c), u.ID), Bytes: ByteLimit(c.Store, limitsOf(c), u.ID),
292 d := out{Org: org.Name, Repos: RepoLimit(c.Store, limitsOf(c), "org", org.ID), Bytes: ByteLimit(c.Store, limitsOf(c), "org", org.ID),
172293 Override: l.Repos != nil || l.Bytes != nil}
173 d.ReposOwned, _ = c.Store.OwnedRepoCount(u.ID)
174 d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, u.ID)
294 d.ReposOwned, _ = c.Store.OwnedRepoCount("org", org.ID)
295 d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, "org", org.ID)
175296 return c.emit(d, func(w io.Writer) {
176 cap := func(n int64) string {
177 if n == 0 {
178 return "unlimited"
179 }
180 return strconv.FormatInt(n, 10)
181 }
182 fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\n", d.User, d.ReposOwned, cap(d.Repos), d.BytesOwned, cap(d.Bytes))
297 fmt.Fprintf(w, "%s\trepos %d of %s\tbytes %d of %s\n", d.Org, d.ReposOwned, capText(d.Repos), d.BytesOwned, capText(d.Bytes))
183298 })
184299}
internal/control/repo.go +13 −5
@@ -276,11 +276,9 @@ func runRepoCreate(c *Ctx, args []string) int {
276276 return code
277277 }
278278 repoCreateMu.Lock()
279 if ownerKind == "user" {
280 if code := checkRepoQuota(c); code >= 0 {
281 repoCreateMu.Unlock()
282 return code
283 }
279 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
280 repoCreateMu.Unlock()
281 return code
284282 }
285283 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
286284 repoCreateMu.Unlock()
@@ -581,6 +579,16 @@ func runRepoTransfer(c *Ctx, args []string) int {
581579
582580 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
583581 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name)
582 // The receiving owner's caps apply as if the repository were created
583 // there. The lock covers the move so two transfers cannot both pass.
584 repoCreateMu.Lock()
585 defer repoCreateMu.Unlock()
586 if code := checkRepoQuota(c, newKind, newID); code >= 0 {
587 return code
588 }
589 if code := checkBytesLeft(c, newKind, newID, newOwner, gitutil.DirSize(oldDir)); code >= 0 {
590 return code
591 }
584592 if _, err := os.Stat(newDir); err == nil {
585593 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
586594 }
internal/sshd/sshd.go +3 −3
@@ -586,9 +586,9 @@ func runGit(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter
586586 // A storage quota on the owner rides the same mechanism as the pack
587587 // cap: the pack may be no larger than what the owner has left.
588588 maxPack := cfg.Limits.MaxPackBytes
589 if write && repo.OwnerKind == "user" {
590 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 {
591 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID)
589 if write {
590 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerKind, repo.OwnerID); limit > 0 {
591 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerKind, repo.OwnerID)
592592 left := limit - used
593593 if left <= 0 {
594594 fmt.Fprintf(stderr, "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit\n", repo.OwnerName, used, limit)
internal/store/adminusers.go +3 −4
@@ -111,11 +111,10 @@ func (s *Store) AdminMailAddresses() ([]string, error) {
111111 return out, rows.Err()
112112}
113113
114// OwnedRepoCount counts repositories the user owns directly, not through
115// an org.
116func (s *Store) OwnedRepoCount(userID int64) (int64, error) {
114// OwnedRepoCount counts repositories a user or an org owns directly.
115func (s *Store) OwnedRepoCount(kind string, id int64) (int64, error) {
117116 var n int64
118 err := s.DB.QueryRow("SELECT COUNT(*) FROM repos WHERE owner_kind = 'user' AND owner_id = ?", userID).Scan(&n)
117 err := s.DB.QueryRow("SELECT COUNT(*) FROM repos WHERE owner_kind = ? AND owner_id = ?", kind, id).Scan(&n)
119118 return n, err
120119}
121120
internal/store/migrations/0076_org_limits.down.sql added +5
@@ -0,0 +1,5 @@
1DROP INDEX orgs_created_by;
2ALTER TABLE users DROP COLUMN org_limit;
3ALTER TABLE orgs DROP COLUMN byte_limit;
4ALTER TABLE orgs DROP COLUMN repo_limit;
5ALTER TABLE orgs DROP COLUMN created_by;
internal/store/migrations/0076_org_limits.up.sql added +14
@@ -0,0 +1,14 @@
1-- Quotas for organizations: limits.max_orgs_per_user counts orgs by the
2-- account that created them; max_repos_per_org and max_bytes_per_org cap
3-- each org, with per-org overrides. NULL means the configured default.
4-- created_by carries no foreign key: ids are never reused (#306), and an
5-- org outlives the account that made it.
6ALTER TABLE orgs ADD COLUMN created_by INTEGER;
7ALTER TABLE orgs ADD COLUMN repo_limit INTEGER;
8ALTER TABLE orgs ADD COLUMN byte_limit INTEGER;
9ALTER TABLE users ADD COLUMN org_limit INTEGER;
10UPDATE orgs SET created_by = (
11 SELECT m.user_id FROM org_members m
12 WHERE m.org_id = orgs.id AND m.role = 'admin'
13 ORDER BY m.rowid LIMIT 1);
14CREATE INDEX orgs_created_by ON orgs(created_by);
internal/store/orgs.go +1 −1
@@ -43,7 +43,7 @@ func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) {
4343 if taken {
4444 return 0, fmt.Errorf("the name %q is taken", name)
4545 }
46 res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name)
46 res, err := tx.Exec("INSERT INTO orgs (name, created_by) VALUES (?, ?)", name, creatorID)
4747 if err != nil {
4848 return 0, err
4949 }
internal/store/quotas.go +46 −23
@@ -5,39 +5,54 @@ import (
55 "time"
66)
77
8// UserLimits is an account's quota overrides; nil means the configured
9// default applies.
10type UserLimits struct {
8// Limits is an owner's quota overrides; nil means the configured default
9// applies. Orgs is the account's cap on organizations it creates and is
10// always nil for an org.
11type Limits struct {
1112 Repos *int64
1213 Bytes *int64
14 Orgs *int64
1315}
1416
15func (s *Store) UserLimits(userID int64) (UserLimits, error) {
16 var repos, bytes sql.NullInt64
17 err := s.DB.QueryRow("SELECT repo_limit, byte_limit FROM users WHERE id = ?", userID).Scan(&repos, &bytes)
18 if err != nil {
19 return UserLimits{}, err
20 }
21 var l UserLimits
22 if repos.Valid {
23 l.Repos = &repos.Int64
17func nullable(n sql.NullInt64) *int64 {
18 if !n.Valid {
19 return nil
2420 }
25 if bytes.Valid {
26 l.Bytes = &bytes.Int64
21 return &n.Int64
22}
23
24func orNull(p *int64) any {
25 if p == nil {
26 return nil
2727 }
28 return l, nil
28 return *p
2929}
3030
31// SetUserLimits writes the overrides; a nil field clears back to default.
32func (s *Store) SetUserLimits(userID int64, l UserLimits) error {
33 var repos, bytes any
34 if l.Repos != nil {
35 repos = *l.Repos
31// OwnerLimits reads the overrides of a user or an org.
32func (s *Store) OwnerLimits(kind string, id int64) (Limits, error) {
33 var repos, bytes, orgs sql.NullInt64
34 var err error
35 if kind == "org" {
36 err = s.DB.QueryRow("SELECT repo_limit, byte_limit FROM orgs WHERE id = ?", id).Scan(&repos, &bytes)
37 } else {
38 err = s.DB.QueryRow("SELECT repo_limit, byte_limit, org_limit FROM users WHERE id = ?", id).Scan(&repos, &bytes, &orgs)
39 }
40 if err != nil {
41 return Limits{}, err
3642 }
37 if l.Bytes != nil {
38 bytes = *l.Bytes
43 return Limits{nullable(repos), nullable(bytes), nullable(orgs)}, nil
44}
45
46// SetOwnerLimits writes the overrides; a nil field clears back to default.
47func (s *Store) SetOwnerLimits(kind string, id int64, l Limits) error {
48 var res sql.Result
49 var err error
50 if kind == "org" {
51 res, err = s.DB.Exec("UPDATE orgs SET repo_limit = ?, byte_limit = ? WHERE id = ?", orNull(l.Repos), orNull(l.Bytes), id)
52 } else {
53 res, err = s.DB.Exec("UPDATE users SET repo_limit = ?, byte_limit = ?, org_limit = ? WHERE id = ?",
54 orNull(l.Repos), orNull(l.Bytes), orNull(l.Orgs), id)
3955 }
40 res, err := s.DB.Exec("UPDATE users SET repo_limit = ?, byte_limit = ? WHERE id = ?", repos, bytes, userID)
4156 if err != nil {
4257 return err
4358 }
@@ -47,6 +62,14 @@ func (s *Store) SetUserLimits(userID int64, l UserLimits) error {
4762 return nil
4863}
4964
65// CreatedOrgCount counts the organizations an account created and that
66// still exist.
67func (s *Store) CreatedOrgCount(userID int64) (int64, error) {
68 var n int64
69 err := s.DB.QueryRow("SELECT COUNT(*) FROM orgs WHERE created_by = ?", userID).Scan(&n)
70 return n, err
71}
72
5073// ReapPendingUsers deletes self-registered accounts still unverified
5174// after maxAge. A pending account owns nothing (it cannot create a
5275// repository before verifying), so DeleteUser has nothing to refuse; an