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=.
358- =max_snippet_bytes= (1MB) — cap per snippet file. 358- =max_snippet_bytes= (1MB) — cap per snippet file.
359- =max_snippets_per_user= (0, unlimited) — snippets an account may own. 359- =max_snippets_per_user= (0, unlimited) — snippets an account may own.
360- =max_repos_per_user= (0, unlimited) — repositories an account may own 360- =max_repos_per_user= (0, unlimited) — repositories an account may own
361 directly; =repo create=, =fork= and =import= refuse past it. 361 directly; =repo create=, =fork=, =import= and =repo transfer= refuse
362 Organizations are not capped. 362 past it.
363- =max_bytes_per_user= (0, unlimited) — disk the account's own 363- =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.
365- =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32), 370- =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32),
366 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches, 371 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches,
367 =git archive --remote=, web archive downloads, =repo download= over 372 =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
520ssh git@<host> audit ... # the same, from an admin session 525ssh git@<host> audit ... # the same, from an admin session
521ssh git@<host> admin user list [--state active|pending|disabled|admin] 526ssh git@<host> admin user list [--state active|pending|disabled|admin]
522ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions 527ssh 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
524ssh git@<host> admin user promote <name> # grant instance admin 530ssh git@<host> admin user promote <name> # grant instance admin
525ssh git@<host> admin user demote <name> # remove it; the last admin is refused 531ssh git@<host> admin user demote <name> # remove it; the last admin is refused
526gitbayd admin user promote <name> # host-local: recovery when no admin key is reachable 532gitbayd 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.
478| disable, enable | yes | yes | no | 478| disable, enable | yes | yes | no |
479| account create, delete | yes | no | no | 479| account create, delete | yes | no | no |
480| invite | yes | no | no | 480| invite | yes | no | no |
481| account and org quotas | yes | no | no |
481| worker queues | yes | yes | no | 482| worker queues | yes | yes | no |
482| runners | yes | no | no | 483| runners | yes | no | no |
483| repository list, archive, visibility | yes | no | no | 484| repository list, archive, visibility | yes | no | no |
cmd/gitbay/main.go +3
@@ -129,6 +129,9 @@ func newRoot() *cobra.Command {
129 pass("delete", passOpts{server: []string{"admin", "user", "delete"}}), 129 pass("delete", passOpts{server: []string{"admin", "user", "delete"}}),
130 pass("limits", passOpts{server: []string{"admin", "user", "limits"}}), 130 pass("limits", passOpts{server: []string{"admin", "user", "limits"}}),
131 ), 131 ),
132 group("org", "any organization",
133 pass("limits", passOpts{server: []string{"admin", "org", "limits"}}),
134 ),
132 group("email", "addresses on any account", 135 group("email", "addresses on any account",
133 pass("verify", passOpts{server: []string{"admin", "email", "verify"}}), 136 pass("verify", passOpts{server: []string{"admin", "email", "verify"}}),
134 ), 137 ),
cmd/gitbay/summaries_gen.go +2 −1
@@ -9,6 +9,7 @@ var summaries = map[string]string{
9 "admin invite": "issue a registration invite and mail its code", 9 "admin invite": "issue a registration invite and mail its code",
10 "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting", 10 "admin mail inbound check": "connect to the reply mailbox read-only and report what is waiting",
11 "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)", 11 "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)",
12 "admin repo archive": "archive any repository (instance admins; audited)", 13 "admin repo archive": "archive any repository (instance admins; audited)",
13 "admin repo delete": "delete any repository (instance admins; audited)", 14 "admin repo delete": "delete any repository (instance admins; audited)",
14 "admin repo list": "list every repository with size and last push (instance admins)", 15 "admin repo list": "list every repository with size and last push (instance admins)",
@@ -24,7 +25,7 @@ var summaries = map[string]string{
24 "admin user demote": "remove instance admin from an account (never the last one)", 25 "admin user demote": "remove instance admin from an account (never the last one)",
25 "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled", 26 "admin user disable": "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
26 "admin user enable": "restore a suspended account", 27 "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)",
28 "admin user list": "list accounts (instance admins)", 29 "admin user list": "list accounts (instance admins)",
29 "admin user promote": "make an account an instance admin", 30 "admin user promote": "make an account an instance admin",
30 "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)", 31 "admin user show": "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
e2e/quota_test.go +32 −4
@@ -10,14 +10,16 @@ import (
10 "time" 10 "time"
11) 11)
12 12
13// Per-account caps on repositories and storage, with the admin override, 13// Per-account and per-org caps on repositories and storage, the cap on
14// and expiry of accounts that never verified. 14// orgs an account creates, the admin overrides, and expiry of accounts
15// that never verified.
15func TestQuotasAndPendingExpiry(t *testing.T) { 16func TestQuotasAndPendingExpiry(t *testing.T) {
16 t.Setenv("GITBAY_REAP_TICK", "500ms") 17 t.Setenv("GITBAY_REAP_TICK", "500ms")
17 smtp := startFakeSMTP(t) 18 smtp := startFakeSMTP(t)
18 inst := startInstanceWith(t, fmt.Sprintf( 19 inst := startInstanceWith(t, fmt.Sprintf(
19 "[registration]\nmode = \"open\"\npending_expiry = \"2s\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n"+ 20 "[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))
21 rootKey := inst.newKey(t, "root") 23 rootKey := inst.newKey(t, "root")
22 aliceKey := inst.newKey(t, "alice") 24 aliceKey := inst.newKey(t, "alice")
23 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin") 25 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin")
@@ -35,13 +37,32 @@ func TestQuotasAndPendingExpiry(t *testing.T) {
35 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "fork", "alice/one", "--name", "onefork"); code != 4 { 37 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "fork", "alice/one", "--name", "onefork"); code != 4 {
36 t.Fatal("fork slipped past the cap") 38 t.Fatal("fork slipped past the cap")
37 } 39 }
38 // An org is not capped. 40 // One org fits; the second is refused.
39 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 { 41 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "acme"); code != 0 {
40 t.Fatal("org create failed") 42 t.Fatal("org create failed")
41 } 43 }
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.
42 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib"); code != 0 { 48 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "acme/lib"); code != 0 {
43 t.Fatalf("org repo: %s", errOut) 49 t.Fatalf("org repo: %s", errOut)
44 } 50 }
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 }
45 // The admin raises the cap for this account; the third fits. 66 // The admin raises the cap for this account; the third fits.
46 if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "3"); code != 0 || !strings.Contains(out, "repos 2 of 3") { 67 if out, _, code := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "3"); code != 0 || !strings.Contains(out, "repos 2 of 3") {
47 t.Fatalf("limits: exit %d %s", code, out) 68 t.Fatalf("limits: exit %d %s", code, out)
@@ -55,6 +76,13 @@ func TestQuotasAndPendingExpiry(t *testing.T) {
55 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "default"); !strings.Contains(out, "of 2") { 76 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "limits", "alice", "--repos", "default"); !strings.Contains(out, "of 2") {
56 t.Fatalf("limits back to default:\n%s", out) 77 t.Fatalf("limits back to default:\n%s", out)
57 } 78 }
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 }
58 86
59 // Storage: a push past what the account has left is refused. 87 // Storage: a push past what the account has left is refused.
60 work := t.TempDir() 88 work := t.TempDir()
internal/config/config.go +9 −5
@@ -239,11 +239,14 @@ type Limits struct {
239 // counted in the dispatcher so every surface shares one budget. 0 uses 239 // counted in the dispatcher so every surface shares one budget. 0 uses
240 // the default; a negative value turns the limit off. 240 // the default; a negative value turns the limit off.
241 WriteRate int `toml:"write_rate"` 241 WriteRate int `toml:"write_rate"`
242 // Per-account quotas on what a user owns directly (organizations are 242 // Quotas on what a user or an org owns directly, and on the orgs an
243 // not capped). 0 means unlimited; admin user limits overrides per 243 // account creates. 0 means unlimited; admin user limits and admin org
244 // account. 244 // limits override per owner.
245 MaxReposPerUser int `toml:"max_repos_per_user"` 245 MaxReposPerUser int `toml:"max_repos_per_user"`
246 MaxBytesPerUser int64 `toml:"max_bytes_per_user"` 246 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"`
247 // PackConcurrency caps git pack generation (upload-pack and 250 // PackConcurrency caps git pack generation (upload-pack and
248 // upload-archive) running at once across SSH, smart HTTP and git://. 251 // upload-archive) running at once across SSH, smart HTTP and git://.
249 // PackPerPrincipal caps it per account, or per client address on the 252 // PackPerPrincipal caps it per account, or per client address on the
@@ -675,8 +678,9 @@ func (c Config) Validate() error {
675 errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry)) 678 errs = append(errs, fmt.Errorf("registration.pending_expiry %q must be a positive duration such as 168h", c.Registration.PendingExpiry))
676 } 679 }
677 } 680 }
678 if c.Limits.MaxReposPerUser < 0 || c.Limits.MaxBytesPerUser < 0 || c.Limits.MaxSnippetsPerUser < 0 { 681 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")) 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"))
680 } 684 }
681 for _, w := range []struct{ name, val string }{ 685 for _, w := range []struct{ name, val string }{
682 {"pack_queue_wait", c.Limits.PackQueueWait}, 686 {"pack_queue_wait", c.Limits.PackQueueWait},
internal/control/admin.go +3 −3
@@ -283,11 +283,11 @@ func runAdminUserShow(c *Ctx, args []string) int {
283 for _, m := range orgs { 283 for _, m := range orgs {
284 d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role}) 284 d.Orgs = append(d.Orgs, orgOut{m.Username, m.Role})
285 } 285 }
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 {
287 return c.fail(protocol.ExitFailure, "%v", err) 287 return c.fail(protocol.ExitFailure, "%v", err)
288 } 288 }
289 d.RepoLimit = RepoLimit(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), u.ID) 290 d.ByteLimit = ByteLimit(c.Store, limitsOf(c), "user", u.ID)
291 tokens, err := c.Store.ListAPITokens(u.ID) 291 tokens, err := c.Store.ListAPITokens(u.ID)
292 if err != nil { 292 if err != nil {
293 return c.fail(protocol.ExitFailure, "%v", err) 293 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/import.go +5 −9
@@ -76,10 +76,8 @@ func runRepoImport(c *Ctx, args []string) int {
76 } 76 }
77 ownerKind, ownerID = "org", org.ID 77 ownerKind, ownerID = "org", org.ID
78 } 78 }
79 if ownerKind == "user" { 79 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
80 if code := checkRepoQuota(c); code >= 0 { 80 return code
81 return code
82 }
83 } 81 }
84 82
85 // http and https only. git:// has no equivalent of curl's resolve 83 // http and https only. git:// has no equivalent of curl's resolve
@@ -138,11 +136,9 @@ func runRepoImport(c *Ctx, args []string) int {
138 // The early check above fails fast; this one holds the lock across 136 // The early check above fails fast; this one holds the lock across
139 // the insert so a concurrent create cannot slip past the count. 137 // the insert so a concurrent create cannot slip past the count.
140 repoCreateMu.Lock() 138 repoCreateMu.Lock()
141 if ownerKind == "user" { 139 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
142 if code := checkRepoQuota(c); code >= 0 { 140 repoCreateMu.Unlock()
143 repoCreateMu.Unlock() 141 return code
144 return code
145 }
146 } 142 }
147 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) 143 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
148 repoCreateMu.Unlock() 144 repoCreateMu.Unlock()
internal/control/mr.go +3 −7
@@ -244,13 +244,9 @@ func runRepoFork(c *Ctx, args []string) int {
244 return code 244 return code
245 } 245 }
246 repoCreateMu.Lock() 246 repoCreateMu.Lock()
247 // An organization's repositories are not counted against the quota, 247 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
248 // the same as repo create. 248 repoCreateMu.Unlock()
249 if ownerKind == "user" { 249 return code
250 if code := checkRepoQuota(c); code >= 0 {
251 repoCreateMu.Unlock()
252 return code
253 }
254 } 250 }
255 id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID) 251 id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID)
256 repoCreateMu.Unlock() 252 repoCreateMu.Unlock()
internal/control/org.go +8 −1
@@ -80,7 +80,14 @@ func runOrgCreate(c *Ctx, args []string) int {
80 if err := policy.ValidateOwnerName(args[0]); err != nil { 80 if err := policy.ValidateOwnerName(args[0]); err != nil {
81 return c.failInput(err) 81 return c.failInput(err)
82 } 82 }
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 {
84 return c.fail(protocol.ExitFailure, "%v", err) 91 return c.fail(protocol.ExitFailure, "%v", err)
85 } 92 }
86 return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) { 93 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 (
12 "gitbay.org/gitbay/internal/store" 12 "gitbay.org/gitbay/internal/store"
13) 13)
14 14
15// Quotas cap what one account owns directly. The limit is the account's 15// Quotas cap what a user or an org owns directly, and how many orgs an
16// override when set, else the configured default; 0 is unlimited. 16// account creates. The limit is the owner's override when set, else the
17// configured default; 0 is unlimited.
17 18
18// RepoLimit is the account's repository cap, 0 for none. 19// RepoLimit is the owner's repository cap, 0 for none.
19func RepoLimit(st *store.Store, cfg configLimits, userID int64) int64 { 20func RepoLimit(st *store.Store, cfg configLimits, kind string, id int64) int64 {
20 if l, err := st.UserLimits(userID); err == nil && l.Repos != nil { 21 if l, err := st.OwnerLimits(kind, id); err == nil && l.Repos != nil {
21 return *l.Repos 22 return *l.Repos
22 } 23 }
24 if kind == "org" {
25 return int64(cfg.MaxReposPerOrg)
26 }
23 return int64(cfg.MaxReposPerUser) 27 return int64(cfg.MaxReposPerUser)
24} 28}
25 29
26// ByteLimit is the account's storage cap in bytes, 0 for none. 30// ByteLimit is the owner's storage cap in bytes, 0 for none.
27func ByteLimit(st *store.Store, cfg configLimits, userID int64) int64 { 31func ByteLimit(st *store.Store, cfg configLimits, kind string, id int64) int64 {
28 if l, err := st.UserLimits(userID); err == nil && l.Bytes != nil { 32 if l, err := st.OwnerLimits(kind, id); err == nil && l.Bytes != nil {
29 return *l.Bytes 33 return *l.Bytes
30 } 34 }
35 if kind == "org" {
36 return cfg.MaxBytesPerOrg
37 }
31 return cfg.MaxBytesPerUser 38 return cfg.MaxBytesPerUser
32} 39}
33 40
34// OwnedBytes is the disk taken by the repositories a user owns directly. 41// OrgLimit is the account's cap on organizations it creates, 0 for none.
35func OwnedBytes(st *store.Store, root string, userID int64) int64 { 42func OrgLimit(st *store.Store, cfg configLimits, userID int64) int64 {
36 repos, err := st.ListReposForOwner("user", userID) 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)
37 if err != nil { 52 if err != nil {
38 return 0 53 return 0
39 } 54 }
@@ -49,33 +64,38 @@ func OwnedBytes(st *store.Store, root string, userID int64) int64 {
49type configLimits struct { 64type configLimits struct {
50 MaxReposPerUser int 65 MaxReposPerUser int
51 MaxBytesPerUser int64 66 MaxBytesPerUser int64
67 MaxOrgsPerUser int
68 MaxReposPerOrg int
69 MaxBytesPerOrg int64
52} 70}
53 71
54// QuotaConfig is what sshd passes: the limits section of the config. 72// QuotaConfig is what sshd passes: the limits section of the config.
55func QuotaConfig(cfg config.Config) configLimits { 73func 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}
57} 76}
58 77
59func limitsOf(c *Ctx) configLimits { 78func limitsOf(c *Ctx) configLimits { return QuotaConfig(c.Cfg) }
60 return configLimits{c.Cfg.Limits.MaxReposPerUser, c.Cfg.Limits.MaxBytesPerUser}
61}
62 79
63// checkRepoQuota refuses a new user-owned repository past the cap. 80// checkRepoQuota refuses one more repository for the owner past its cap.
64// repoCreateMu serialises the quota check with the insert that follows 81// repoCreateMu serialises the quota check with the insert that follows
65// it, so two concurrent creates cannot both pass the count (#108). One 82// it, so two concurrent creates cannot both pass the count (#108). One
66// process serves the instance, so a process-wide lock is the whole story. 83// process serves the instance, so a process-wide lock is the whole story.
67var repoCreateMu sync.Mutex 84var repoCreateMu sync.Mutex
68 85
69func checkRepoQuota(c *Ctx) int { 86func checkRepoQuota(c *Ctx, kind string, id int64) int {
70 limit := RepoLimit(c.Store, limitsOf(c), c.User.ID) 87 limit := RepoLimit(c.Store, limitsOf(c), kind, id)
71 if limit == 0 { 88 if limit == 0 {
72 return -1 89 return -1
73 } 90 }
74 n, err := c.Store.OwnedRepoCount(c.User.ID) 91 n, err := c.Store.OwnedRepoCount(kind, id)
75 if err != nil { 92 if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err) 93 return c.fail(protocol.ExitFailure, "%v", err)
77 } 94 }
78 if n >= limit { 95 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 }
79 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) 99 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)
80 } 100 }
81 return -1 101 return -1
@@ -83,102 +103,197 @@ func checkRepoQuota(c *Ctx) int {
83 103
84// checkStorageQuota refuses a server-side write into repo once its 104// checkStorageQuota refuses a server-side write into repo once its
85// owner's storage quota is used up, the check sshd makes before a push. 105// owner's storage quota is used up, the check sshd makes before a push.
86// Repositories an org owns have no quota.
87func checkStorageQuota(c *Ctx, repo store.Repo) int { 106func checkStorageQuota(c *Ctx, repo store.Repo) int {
88 if repo.OwnerKind != "user" { 107 return checkBytesLeft(c, repo.OwnerKind, repo.OwnerID, repo.OwnerName, 0)
89 return -1 108}
90 } 109
91 limit := ByteLimit(c.Store, limitsOf(c), repo.OwnerID) 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)
92 if limit <= 0 { 114 if limit <= 0 {
93 return -1 115 return -1
94 } 116 }
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 {
96 return c.fail(protocol.ExitDenied, 119 return c.fail(protocol.ExitDenied,
97 "%s's storage quota is used up (%d of %d bytes); delete something, or ask an admin to raise the limit", 120 "%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)
99 } 146 }
100 return -1 147 return -1
101} 148}
102 149
103func init() { 150func init() {
104 register(Command{Path: []string{"admin", "user", "limits"}, 151 register(Command{Path: []string{"admin", "user", "limits"},
105 Summary: "show or set an account's repository and storage caps (instance admins)", 152 Summary: "show or set an account's repository, storage and organization caps (instance admins)",
106 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]", 153 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default] [--orgs <n>|default]",
107 Flags: []Flag{ 154 Flags: []Flag{
108 {"--repos", "<n>|default", "the account's repository cap", ""}, 155 {"--repos", "<n>|default", "the account's repository cap", ""},
109 {"--bytes", "<n>|default", "the account's storage cap", ""}, 156 {"--bytes", "<n>|default", "the account's storage cap", ""},
157 {"--orgs", "<n>|default", "the account's cap on organizations it creates", ""},
110 }, 158 },
111 Examples: []string{"admin user limits alice", "admin user limits alice --repos 50"}, 159 Examples: []string{"admin user limits alice", "admin user limits alice --repos 50"},
112 Run: runAdminUserLimits}) 160 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})
113} 170}
114 171
115func runAdminUserLimits(c *Ctx, args []string) int { 172// applyLimitFlags reads --repos/--bytes (and --orgs when orgs is true)
116 if code := requireInstanceAdmin(c); code >= 0 { 173// into l. set reports whether any flag was given.
117 return code 174func applyLimitFlags(c *Ctx, args []string, l *store.Limits, orgs bool) (set bool, code int) {
118 } 175 for i := 0; i < len(args); i++ {
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++ {
132 if i+1 >= len(args) { 176 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])
134 } 178 }
135 v := args[i+1] 179 v := args[i+1]
136 var target **int64 180 var target **int64
137 switch args[i] { 181 switch {
138 case "--repos": 182 case args[i] == "--repos":
139 target = &l.Repos 183 target = &l.Repos
140 case "--bytes": 184 case args[i] == "--bytes":
141 target = &l.Bytes 185 target = &l.Bytes
186 case args[i] == "--orgs" && orgs:
187 target = &l.Orgs
142 default: 188 default:
143 return c.usage() 189 return false, c.usage()
144 } 190 }
145 if v == "default" { 191 if v == "default" {
146 *target = nil 192 *target = nil
147 } else { 193 } else {
148 n, err := strconv.ParseInt(v, 10, 64) 194 n, err := strconv.ParseInt(v, 10, 64)
149 if err != nil || n < 0 { 195 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])
151 } 197 }
152 *target = &n 198 *target = &n
153 } 199 }
154 set = true 200 set = true
155 i++ 201 i++
156 } 202 }
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 }
157 if set { 232 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 {
159 return c.fail(protocol.ExitFailure, "%v", err) 234 return c.fail(protocol.ExitFailure, "%v", err)
160 } 235 }
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})
162 } 237 }
163 type out struct { 238 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"`
165 Repos int64 `json:"repos"` // effective cap, 0 unlimited 286 Repos int64 `json:"repos"` // effective cap, 0 unlimited
166 Bytes int64 `json:"bytes"` // effective cap, 0 unlimited 287 Bytes int64 `json:"bytes"` // effective cap, 0 unlimited
167 ReposOwned int64 `json:"repos_owned"` 288 ReposOwned int64 `json:"repos_owned"`
168 BytesOwned int64 `json:"bytes_owned"` 289 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
170 } 291 }
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),
172 Override: l.Repos != nil || l.Bytes != nil} 293 Override: l.Repos != nil || l.Bytes != nil}
173 d.ReposOwned, _ = c.Store.OwnedRepoCount(u.ID) 294 d.ReposOwned, _ = c.Store.OwnedRepoCount("org", org.ID)
174 d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, u.ID) 295 d.BytesOwned = OwnedBytes(c.Store, c.Cfg.Server.Root, "org", org.ID)
175 return c.emit(d, func(w io.Writer) { 296 return c.emit(d, func(w io.Writer) {
176 cap := func(n int64) string { 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))
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))
183 }) 298 })
184} 299}
internal/control/repo.go +13 −5
@@ -276,11 +276,9 @@ func runRepoCreate(c *Ctx, args []string) int {
276 return code 276 return code
277 } 277 }
278 repoCreateMu.Lock() 278 repoCreateMu.Lock()
279 if ownerKind == "user" { 279 if code := checkRepoQuota(c, ownerKind, ownerID); code >= 0 {
280 if code := checkRepoQuota(c); code >= 0 { 280 repoCreateMu.Unlock()
281 repoCreateMu.Unlock() 281 return code
282 return code
283 }
284 } 282 }
285 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) 283 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
286 repoCreateMu.Unlock() 284 repoCreateMu.Unlock()
@@ -581,6 +579,16 @@ func runRepoTransfer(c *Ctx, args []string) int {
581 579
582 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) 580 oldDir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
583 newDir := RepoDir(c.Cfg.Server.Root, newOwner, repo.Name) 581 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 }
584 if _, err := os.Stat(newDir); err == nil { 592 if _, err := os.Stat(newDir); err == nil {
585 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name) 593 return c.fail(protocol.ExitFailure, "repository directory already exists at %s/%s", newOwner, repo.Name)
586 } 594 }
internal/sshd/sshd.go +3 −3
@@ -586,9 +586,9 @@ func runGit(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter
586 // A storage quota on the owner rides the same mechanism as the pack 586 // A storage quota on the owner rides the same mechanism as the pack
587 // cap: the pack may be no larger than what the owner has left. 587 // cap: the pack may be no larger than what the owner has left.
588 maxPack := cfg.Limits.MaxPackBytes 588 maxPack := cfg.Limits.MaxPackBytes
589 if write && repo.OwnerKind == "user" { 589 if write {
590 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerID); limit > 0 { 590 if limit := control.ByteLimit(st, control.QuotaConfig(cfg), repo.OwnerKind, repo.OwnerID); limit > 0 {
591 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerID) 591 used := control.OwnedBytes(st, cfg.Server.Root, repo.OwnerKind, repo.OwnerID)
592 left := limit - used 592 left := limit - used
593 if left <= 0 { 593 if left <= 0 {
594 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) 594 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) {
111 return out, rows.Err() 111 return out, rows.Err()
112} 112}
113 113
114// OwnedRepoCount counts repositories the user owns directly, not through 114// OwnedRepoCount counts repositories a user or an org owns directly.
115// an org. 115func (s *Store) OwnedRepoCount(kind string, id int64) (int64, error) {
116func (s *Store) OwnedRepoCount(userID int64) (int64, error) {
117 var n int64 116 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)
119 return n, err 118 return n, err
120} 119}
121 120
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) {
43 if taken { 43 if taken {
44 return 0, fmt.Errorf("the name %q is taken", name) 44 return 0, fmt.Errorf("the name %q is taken", name)
45 } 45 }
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)
47 if err != nil { 47 if err != nil {
48 return 0, err 48 return 0, err
49 } 49 }
internal/store/quotas.go +46 −23
@@ -5,39 +5,54 @@ import (
5 "time" 5 "time"
6) 6)
7 7
8// UserLimits is an account's quota overrides; nil means the configured 8// Limits is an owner's quota overrides; nil means the configured default
9// default applies. 9// applies. Orgs is the account's cap on organizations it creates and is
10type UserLimits struct { 10// always nil for an org.
11type Limits struct {
11 Repos *int64 12 Repos *int64
12 Bytes *int64 13 Bytes *int64
14 Orgs *int64
13} 15}
14 16
15func (s *Store) UserLimits(userID int64) (UserLimits, error) { 17func nullable(n sql.NullInt64) *int64 {
16 var repos, bytes sql.NullInt64 18 if !n.Valid {
17 err := s.DB.QueryRow("SELECT repo_limit, byte_limit FROM users WHERE id = ?", userID).Scan(&repos, &bytes) 19 return nil
18 if err != nil {
19 return UserLimits{}, err
20 }
21 var l UserLimits
22 if repos.Valid {
23 l.Repos = &repos.Int64
24 } 20 }
25 if bytes.Valid { 21 return &n.Int64
26 l.Bytes = &bytes.Int64 22}
23
24func orNull(p *int64) any {
25 if p == nil {
26 return nil
27 } 27 }
28 return l, nil 28 return *p
29} 29}
30 30
31// SetUserLimits writes the overrides; a nil field clears back to default. 31// OwnerLimits reads the overrides of a user or an org.
32func (s *Store) SetUserLimits(userID int64, l UserLimits) error { 32func (s *Store) OwnerLimits(kind string, id int64) (Limits, error) {
33 var repos, bytes any 33 var repos, bytes, orgs sql.NullInt64
34 if l.Repos != nil { 34 var err error
35 repos = *l.Repos 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
36 } 42 }
37 if l.Bytes != nil { 43 return Limits{nullable(repos), nullable(bytes), nullable(orgs)}, nil
38 bytes = *l.Bytes 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)
39 } 55 }
40 res, err := s.DB.Exec("UPDATE users SET repo_limit = ?, byte_limit = ? WHERE id = ?", repos, bytes, userID)
41 if err != nil { 56 if err != nil {
42 return err 57 return err
43 } 58 }
@@ -47,6 +62,14 @@ func (s *Store) SetUserLimits(userID int64, l UserLimits) error {
47 return nil 62 return nil
48} 63}
49 64
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
50// ReapPendingUsers deletes self-registered accounts still unverified 73// ReapPendingUsers deletes self-registered accounts still unverified
51// after maxAge. A pending account owns nothing (it cannot create a 74// after maxAge. A pending account owns nothing (it cannot create a
52// repository before verifying), so DeleteUser has nothing to refuse; an 75// repository before verifying), so DeleteUser has nothing to refuse; an