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

merged merged by cmc on 2026-09-08 02:35 UTC · krz/gitbay:effective-access into main

6 files changed, +247 −25

Layout: unified · split

.gitbay/wiki/Users.org +5 −1
@@ -191,6 +191,7 @@ Access and settings (owner or =admin= grant):
191#+begin_src sh 191#+begin_src sh
192gitbay repo access grant you/project alice write # read | write | admin 192gitbay repo access grant you/project alice write # read | write | admin
193gitbay repo access revoke you/project alice 193gitbay repo access revoke you/project alice
194gitbay repo access list you/project # everyone who can reach it, role, and via what
194gitbay repo settings protect you/project main # no force-push, no delete 195gitbay repo settings protect you/project main # no force-push, no delete
195gitbay repo settings require-mr you/project on # protected branches: merge requests only 196gitbay repo settings require-mr you/project on # protected branches: merge requests only
196gitbay repo settings protect-tag you/project 'v*' # matching tags: created once, never moved or deleted 197gitbay 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.
252 253
253Orgs share the owner namespace with users and own repositories at 254Orgs share the owner namespace with users and own repositories at
254=org/repo=. By default members get write on all org repos; org admins 255=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.
256 260
257#+begin_src sh 261#+begin_src sh
258gitbay org create krz 262gitbay 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() {
50 Summary: "revoke access", 50 Summary: "revoke access",
51 Usage: "repo access revoke <owner/name> <user>", Run: runAccessRevoke}) 51 Usage: "repo access revoke <owner/name> <user>", Run: runAccessRevoke})
52 register(Command{Path: []string{"repo", "access", "list"}, 52 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",
54 Usage: "repo access list <owner/name>", ReadOnly: true, Run: runAccessList}) 54 Usage: "repo access list <owner/name>", ReadOnly: true, Run: runAccessList})
55 register(Command{Path: []string{"repo", "settings", "show"}, 55 register(Command{Path: []string{"repo", "settings", "show"},
56 Summary: "show settings", 56 Summary: "show settings",
@@ -585,21 +585,22 @@ func runAccessList(c *Ctx, args []string) int {
585 if code >= 0 { 585 if code >= 0 {
586 return code 586 return code
587 } 587 }
588 entries, err := c.Store.ListAccess(repo.ID) 588 entries, err := c.Store.EffectiveAccess(repo.ID)
589 if err != nil { 589 if err != nil {
590 return c.fail(protocol.ExitFailure, "%v", err) 590 return c.fail(protocol.ExitFailure, "%v", err)
591 } 591 }
592 type out struct { 592 type out struct {
593 User string `json:"user"` 593 User string `json:"user"`
594 Role string `json:"role"` 594 Role string `json:"role"`
595 Source string `json:"source"`
595 } 596 }
596 var ds []out 597 var ds []out
597 for _, e := range entries { 598 for _, e := range entries {
598 ds = append(ds, out{e.Username, e.Role}) 599 ds = append(ds, out{e.Username, e.Role, e.Source})
599 } 600 }
600 return c.emit(ds, func(w io.Writer) { 601 return c.emit(ds, func(w io.Writer) {
601 for _, d := range ds { 602 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)
603 } 604 }
604 }) 605 })
605} 606}
internal/control/teams.go +4 −2
@@ -58,7 +58,9 @@ func orgAdminRef(c *Ctx, name string) (store.Org, int) {
58 return org, -1 58 return org, -1
59} 59}
60 60
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.
62func orgMemberRef(c *Ctx, name string) (store.Org, int) { 64func orgMemberRef(c *Ctx, name string) (store.Org, int) {
63 org, err := c.Store.OrgByName(name) 65 org, err := c.Store.OrgByName(name)
64 if err != nil { 66 if err != nil {
@@ -69,7 +71,7 @@ func orgMemberRef(c *Ctx, name string) (store.Org, int) {
69 return org, c.fail(protocol.ExitFailure, "%v", err) 71 return org, c.fail(protocol.ExitFailure, "%v", err)
70 } 72 }
71 if role == "" { 73 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)
73 } 75 }
74 return org, -1 76 return org, -1
75} 77}
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 (
6 "encoding/json" 6 "encoding/json"
7 "errors" 7 "errors"
8 "fmt" 8 "fmt"
9 "sort"
9 "strings" 10 "strings"
10) 11)
11 12
@@ -305,29 +306,92 @@ func (s *Store) RevokeAccess(repoID, userID int64) error {
305 return nil 306 return nil
306} 307}
307 308
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 {
309 Username string 313 Username string
310 Role string 314 Role string
315 Source string
311} 316}
312 317
313func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) { 318// EffectiveAccess lists every account that can reach a repository with
314 rows, err := s.DB.Query(` 319// the highest role it holds and where that role comes from. Direct
315 SELECT u.username, a.role FROM repo_access a 320// grants, org roles and team grants are folded together the way
316 JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id 321// AccessRole folds them for one account.
317 WHERE a.repo_id = ? ORDER BY u.username`, repoID) 322func (s *Store) EffectiveAccess(repoID int64) ([]EffectiveEntry, error) {
318 if err != nil { 323 rank := map[string]int{"read": 1, "write": 2, "admin": 3}
319 return nil, err 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 }
320 } 337 }
321 defer rows.Close() 338 collect := func(query string, source func(extra string) string, args ...any) error {
322 var out []AccessEntry 339 rows, err := s.DB.Query(query, args...)
323 for rows.Next() { 340 if err != nil {
324 var e AccessEntry 341 return err
325 if err := rows.Scan(&e.Username, &e.Role); err != nil {
326 return nil, err
327 } 342 }
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()
329 } 352 }
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
331} 395}
332 396
333func (s *Store) RepoByID(id int64) (Repo, error) { 397func (s *Store) RepoByID(id int64) (Repo, error) {