Commit 88fc476788

88fc476788ab9c640649427d815217360569b4d9

parent: 060109696d

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

cmc <hello@cleberg.net> · 2026-09-07 22:32 UTC

store, control, wiki: repo access list reports effective access; one rule for outsiders

repo access list listed the repo_access rows, so a repository reached
entirely through teams and org roles answered with nothing. It now
folds owner, org admin, direct grant, team grant and org membership
into one row per account with the highest role and its source, the way
AccessRole resolves one account. ListAccess had no other caller and
goes.

An outsider asking about an org got two answers: org show and org
members list returned the members, org team list and show said no
organization. The rule is now one: an org's existence and members are
public, its teams are for members, and a non-member is refused rather
than told the org does not exist.

Closes #200
.gitbay/wiki/Users.org +5 −1
@@ -191,6 +191,7 @@ Access and settings (owner or =admin= grant):
191191#+begin_src sh
192192gitbay repo access grant you/project alice write # read | write | admin
193193gitbay repo access revoke you/project alice
194gitbay repo access list you/project # everyone who can reach it, role, and via what
194195gitbay repo settings protect you/project main # no force-push, no delete
195196gitbay repo settings require-mr you/project on # protected branches: merge requests only
196197gitbay repo settings protect-tag you/project 'v*' # matching tags: created once, never moved or deleted
@@ -252,7 +253,10 @@ shows the text instead. Other files show the text with highlighting.
252253
253254Orgs share the owner namespace with users and own repositories at
254255=org/repo=. By default members get write on all org repos; org admins
255get repo admin, create repos under the org, and manage membership.
256get repo admin, create repos under the org, and manage membership. An
257org's existence and member list are public; its teams are visible to
258members, and anyone else asking is refused rather than told the org
259does not exist.
256260
257261#+begin_src sh
258262gitbay org create krz
e2e/effectiveaccess_test.go added +68
@@ -0,0 +1,68 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8// repo access list reports effective access with its source, and an
9// outsider gets one answer about an org: it exists and has members;
10// its teams are not theirs to see (#200).
11func TestEffectiveAccessAndOutsiders(t *testing.T) {
12 inst := startInstance(t)
13 keys := map[string]string{}
14 for _, u := range []string{"alice", "bob", "carol", "dave", "eve"} {
15 keys[u] = inst.newKey(t, u)
16 inst.admin(t, "admin", "user", "create", u, "--key", keys[u]+".pub")
17 }
18 for _, args := range [][]string{
19 {"org", "create", "acme"},
20 {"org", "members", "add", "acme", "bob"},
21 {"org", "members", "add", "acme", "carol"},
22 {"org", "members", "add", "acme", "dave"},
23 {"org", "settings", "members-role", "acme", "read"},
24 {"repo", "create", "acme/core", "--private"},
25 {"org", "team", "create", "acme", "core"},
26 {"org", "team", "add", "acme", "core", "bob"},
27 {"org", "team", "grant", "acme", "core", "acme/core", "write"},
28 {"repo", "access", "grant", "acme/core", "carol", "admin"},
29 } {
30 if _, errOut, code := inst.ssh(t, keys["alice"], "", args...); code != 0 {
31 t.Fatalf("%v: %s", args, errOut)
32 }
33 }
34 out, _, code := inst.ssh(t, keys["alice"], "", "repo", "access", "list", "acme/core", "--json")
35 if code != 0 {
36 t.Fatalf("access list: %s", out)
37 }
38 for _, want := range []string{
39 `{"user":"alice","role":"admin","source":"org admin"}`,
40 `{"user":"bob","role":"write","source":"team core"}`,
41 `{"user":"carol","role":"admin","source":"direct"}`,
42 `{"user":"dave","role":"read","source":"org member"}`,
43 } {
44 if !strings.Contains(out, want) {
45 t.Errorf("access list missing %s:\n%s", want, out)
46 }
47 }
48 if strings.Contains(out, `"eve"`) {
49 t.Errorf("outsider listed:\n%s", out)
50 }
51
52 // eve: the org and its members are public, the teams are not.
53 if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "show", "acme"); code != 0 {
54 t.Fatalf("org show for an outsider: %d %s", code, errOut)
55 }
56 if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "members", "list", "acme"); code != 0 {
57 t.Fatalf("org members list for an outsider: %d %s", code, errOut)
58 }
59 if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "team", "list", "acme"); code != 4 {
60 t.Fatalf("org team list for an outsider: %d %s", code, errOut)
61 }
62 if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "team", "show", "acme", "core"); code != 4 {
63 t.Fatalf("org team show for an outsider: %d %s", code, errOut)
64 }
65 if _, _, code := inst.ssh(t, keys["eve"], "", "org", "team", "list", "nosuch"); code != 3 {
66 t.Fatalf("missing org: %d", code)
67 }
68}
internal/control/repo.go +7 −6
@@ -50,7 +50,7 @@ func init() {
5050 Summary: "revoke access",
5151 Usage: "repo access revoke <owner/name> <user>", Run: runAccessRevoke})
5252 register(Command{Path: []string{"repo", "access", "list"},
53 Summary: "list access grants",
53 Summary: "list who can reach the repository, with the role and where it comes from",
5454 Usage: "repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
5555 register(Command{Path: []string{"repo", "settings", "show"},
5656 Summary: "show settings",
@@ -585,21 +585,22 @@ func runAccessList(c *Ctx, args []string) int {
585585 if code >= 0 {
586586 return code
587587 }
588 entries, err := c.Store.ListAccess(repo.ID)
588 entries, err := c.Store.EffectiveAccess(repo.ID)
589589 if err != nil {
590590 return c.fail(protocol.ExitFailure, "%v", err)
591591 }
592592 type out struct {
593 User string `json:"user"`
594 Role string `json:"role"`
593 User string `json:"user"`
594 Role string `json:"role"`
595 Source string `json:"source"`
595596 }
596597 var ds []out
597598 for _, e := range entries {
598 ds = append(ds, out{e.Username, e.Role})
599 ds = append(ds, out{e.Username, e.Role, e.Source})
599600 }
600601 return c.emit(ds, func(w io.Writer) {
601602 for _, d := range ds {
602 fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role)
603 fmt.Fprintf(w, "%s\t%s\tvia %s\n", d.User, d.Role, d.Source)
603604 }
604605 })
605606}
internal/control/teams.go +4 −2
@@ -58,7 +58,9 @@ func orgAdminRef(c *Ctx, name string) (store.Org, int) {
5858 return org, -1
5959}
6060
61// orgMemberRef resolves an org, requiring at least membership.
61// orgMemberRef resolves an org, requiring at least membership. An org's
62// existence and member list are public (org show answers anyone), so a
63// non-member is refused rather than told the org does not exist.
6264func orgMemberRef(c *Ctx, name string) (store.Org, int) {
6365 org, err := c.Store.OrgByName(name)
6466 if err != nil {
@@ -69,7 +71,7 @@ func orgMemberRef(c *Ctx, name string) (store.Org, int) {
6971 return org, c.fail(protocol.ExitFailure, "%v", err)
7072 }
7173 if role == "" {
72 return org, c.fail(protocol.ExitNotFound, "no organization %q", name)
74 return org, c.fail(protocol.ExitDenied, "teams of %s are visible to its members", name)
7375 }
7476 return org, -1
7577}
internal/store/effectiveaccess_test.go added +83
@@ -0,0 +1,83 @@
1package store
2
3import "testing"
4
5// EffectiveAccess folds owner, org roles, team grants and direct grants
6// into one row per account carrying the highest role and its source
7// (#200).
8func TestEffectiveAccess(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13 mk := func(name string) int64 {
14 id, err := s.CreateUser(name, false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 return id
19 }
20 alice, bob, carol, dave, eve := mk("alice"), mk("bob"), mk("carol"), mk("dave"), mk("eve")
21 oid, err := s.CreateOrg("acme", alice)
22 if err != nil {
23 t.Fatal(err)
24 }
25 for _, u := range []int64{bob, carol, dave} {
26 if err := s.SetOrgMember(oid, u, "member"); err != nil {
27 t.Fatal(err)
28 }
29 }
30 if err := s.SetOrgMembersRole(oid, "read"); err != nil {
31 t.Fatal(err)
32 }
33 repo, err := s.CreateRepo("org", oid, "core", "private")
34 if err != nil {
35 t.Fatal(err)
36 }
37 team, err := s.CreateTeam(oid, "core")
38 if err != nil {
39 t.Fatal(err)
40 }
41 if err := s.AddTeamMember(team, bob); err != nil {
42 t.Fatal(err)
43 }
44 if err := s.GrantTeamRepo(team, repo, "write"); err != nil {
45 t.Fatal(err)
46 }
47 if err := s.GrantAccess(repo, carol, "admin"); err != nil {
48 t.Fatal(err)
49 }
50 _ = eve
51
52 got, err := s.EffectiveAccess(repo)
53 if err != nil {
54 t.Fatal(err)
55 }
56 want := []EffectiveEntry{
57 {"alice", "admin", "org admin"},
58 {"bob", "write", "team core"},
59 {"carol", "admin", "direct"},
60 {"dave", "read", "org member"},
61 }
62 if len(got) != len(want) {
63 t.Fatalf("got %v, want %v", got, want)
64 }
65 for i := range want {
66 if got[i] != want[i] {
67 t.Errorf("row %d: got %v, want %v", i, got[i], want[i])
68 }
69 }
70
71 // A user-owned repository: the owner, and nobody else.
72 own, err := s.CreateRepo("user", eve, "mine", "public")
73 if err != nil {
74 t.Fatal(err)
75 }
76 got, err = s.EffectiveAccess(own)
77 if err != nil {
78 t.Fatal(err)
79 }
80 if len(got) != 1 || got[0] != (EffectiveEntry{"eve", "admin", "owner"}) {
81 t.Fatalf("owner row: %v", got)
82 }
83}
internal/store/repos.go +80 −16
@@ -6,6 +6,7 @@ import (
66 "encoding/json"
77 "errors"
88 "fmt"
9 "sort"
910 "strings"
1011)
1112
@@ -305,29 +306,92 @@ func (s *Store) RevokeAccess(repoID, userID int64) error {
305306 return nil
306307}
307308
308type AccessEntry struct {
309// EffectiveEntry is one account's effective role on a repository and the
310// grant it comes from: owner, direct, org admin, org member, or team
311// <name>.
312type EffectiveEntry struct {
309313 Username string
310314 Role string
315 Source string
311316}
312317
313func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) {
314 rows, err := s.DB.Query(`
315 SELECT u.username, a.role FROM repo_access a
316 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id
317 WHERE a.repo_id = ? ORDER BY u.username`, repoID)
318 if err != nil {
319 return nil, err
318// EffectiveAccess lists every account that can reach a repository with
319// the highest role it holds and where that role comes from. Direct
320// grants, org roles and team grants are folded together the way
321// AccessRole folds them for one account.
322func (s *Store) EffectiveAccess(repoID int64) ([]EffectiveEntry, error) {
323 rank := map[string]int{"read": 1, "write": 2, "admin": 3}
324 best := map[string]EffectiveEntry{}
325 var order []string
326 add := func(user, role, source string) {
327 if rank[role] == 0 {
328 return
329 }
330 cur, ok := best[user]
331 if !ok {
332 order = append(order, user)
333 }
334 if !ok || rank[role] > rank[cur.Role] {
335 best[user] = EffectiveEntry{user, role, source}
336 }
320337 }
321 defer rows.Close()
322 var out []AccessEntry
323 for rows.Next() {
324 var e AccessEntry
325 if err := rows.Scan(&e.Username, &e.Role); err != nil {
326 return nil, err
338 collect := func(query string, source func(extra string) string, args ...any) error {
339 rows, err := s.DB.Query(query, args...)
340 if err != nil {
341 return err
327342 }
328 out = append(out, e)
343 defer rows.Close()
344 for rows.Next() {
345 var user, role, extra string
346 if err := rows.Scan(&user, &role, &extra); err != nil {
347 return err
348 }
349 add(user, role, source(extra))
350 }
351 return rows.Err()
329352 }
330 return out, rows.Err()
353 fixed := func(name string) func(string) string { return func(string) string { return name } }
354
355 // The owner: a user outright, or the org's admins and members.
356 if err := collect(`
357 SELECT u.username, 'admin', '' FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
358 WHERE r.id = ?`, fixed("owner"), repoID); err != nil {
359 return nil, err
360 }
361 if err := collect(`
362 SELECT u.username, 'admin', '' FROM repos r
363 JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.role = 'admin'
364 JOIN users u ON u.id = m.user_id WHERE r.id = ?`, fixed("org admin"), repoID); err != nil {
365 return nil, err
366 }
367 if err := collect(`
368 SELECT u.username, a.role, '' FROM repo_access a
369 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id WHERE a.repo_id = ?`,
370 fixed("direct"), repoID); err != nil {
371 return nil, err
372 }
373 if err := collect(`
374 SELECT u.username, tr.role, t.name FROM team_repos tr
375 JOIN teams t ON t.id = tr.team_id
376 JOIN team_members tm ON tm.team_id = tr.team_id
377 JOIN users u ON u.id = tm.user_id WHERE tr.repo_id = ?
378 ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC, t.name`,
379 func(team string) string { return "team " + team }, repoID); err != nil {
380 return nil, err
381 }
382 if err := collect(`
383 SELECT u.username, o.members_role, '' FROM repos r
384 JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
385 JOIN org_members m ON m.org_id = o.id AND m.role = 'member'
386 JOIN users u ON u.id = m.user_id WHERE r.id = ?`, fixed("org member"), repoID); err != nil {
387 return nil, err
388 }
389 sort.Strings(order)
390 out := make([]EffectiveEntry, 0, len(order))
391 for _, u := range order {
392 out = append(out, best[u])
393 }
394 return out, nil
331395}
332396
333397func (s *Store) RepoByID(id int64) (Repo, error) {